loading
Generated 2026-01-19T12:14:28-05:00

All Files ( 4.79% covered at 0.19 hits/line )

112 files in total.
14624 relevant lines, 700 lines covered and 13924 lines missed. ( 4.79% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/channels/application_cable/channel.rb 0.00 % 6 4 0 4 0.00
app/channels/application_cable/connection.rb 0.00 % 6 4 0 4 0.00
app/controllers/api/v1/admin/departments_controller.rb 0.00 % 165 115 0 115 0.00
app/controllers/api/v1/admin/settings_controller.rb 0.00 % 103 88 0 88 0.00
app/controllers/api/v1/admin/signatory_types_controller.rb 0.00 % 175 136 0 136 0.00
app/controllers/api/v1/admin/template_signatories_controller.rb 0.00 % 167 134 0 134 0.00
app/controllers/api/v1/admin/templates_controller.rb 0.00 % 440 340 0 340 0.00
app/controllers/api/v1/admin/users_controller.rb 0.00 % 265 199 0 199 0.00
app/controllers/api/v1/admin/variable_mappings_controller.rb 0.00 % 515 374 0 374 0.00
app/controllers/api/v1/auth/passwords_controller.rb 0.00 % 118 87 0 87 0.00
app/controllers/api/v1/auth/profiles_controller.rb 0.00 % 70 59 0 59 0.00
app/controllers/api/v1/auth/sessions_controller.rb 0.00 % 127 105 0 105 0.00
app/controllers/api/v1/auth/signatures_controller.rb 0.00 % 170 135 0 135 0.00
app/controllers/api/v1/base_controller.rb 0.00 % 67 49 0 49 0.00
app/controllers/api/v1/content/documents_controller.rb 0.00 % 170 134 0 134 0.00
app/controllers/api/v1/content/folders_controller.rb 0.00 % 106 84 0 84 0.00
app/controllers/api/v1/content/versions_controller.rb 0.00 % 111 87 0 87 0.00
app/controllers/api/v1/documents_controller.rb 0.00 % 253 191 0 191 0.00
app/controllers/api/v1/folders_controller.rb 0.00 % 187 148 0 148 0.00
app/controllers/api/v1/health_controller.rb 0.00 % 27 23 0 23 0.00
app/controllers/api/v1/hr/approvals_controller.rb 0.00 % 259 212 0 212 0.00
app/controllers/api/v1/hr/certifications_controller.rb 0.00 % 452 354 0 354 0.00
app/controllers/api/v1/hr/dashboard_controller.rb 0.00 % 36 26 0 26 0.00
app/controllers/api/v1/hr/employees_controller.rb 0.00 % 473 380 0 380 0.00
app/controllers/api/v1/hr/vacations_controller.rb 0.00 % 444 336 0 336 0.00
app/controllers/api/v1/legal/contract_approvals_controller.rb 0.00 % 313 247 0 247 0.00
app/controllers/api/v1/legal/contracts_controller.rb 0.00 % 535 428 0 428 0.00
app/controllers/api/v1/legal/dashboard_controller.rb 0.00 % 102 90 0 90 0.00
app/controllers/api/v1/legal/third_parties_controller.rb 0.00 % 192 155 0 155 0.00
app/controllers/api/v1/legal/third_party_types_controller.rb 0.00 % 124 105 0 105 0.00
app/controllers/api/v1/search_controller.rb 0.00 % 79 66 0 66 0.00
app/controllers/api/v1/templates_controller.rb 0.00 % 121 96 0 96 0.00
app/controllers/api/v1/users_controller.rb 0.00 % 50 41 0 41 0.00
app/controllers/application_controller.rb 0.00 % 64 51 0 51 0.00
app/controllers/frontend_controller.rb 0.00 % 7 5 0 5 0.00
app/jobs/application_job.rb 0.00 % 39 25 0 25 0.00
app/jobs/retention_notification_job.rb 0.00 % 272 196 0 196 0.00
app/jobs/retention_processor_job.rb 0.00 % 181 132 0 132 0.00
app/jobs/sla_check_job.rb 0.00 % 59 37 0 37 0.00
app/jobs/sla_warning_job.rb 0.00 % 33 20 0 20 0.00
app/jobs/workflow_notification_job.rb 0.00 % 351 263 0 263 0.00
app/mailers/application_mailer.rb 0.00 % 6 4 0 4 0.00
app/models/audit/audit_event.rb 78.13 % 344 128 100 28 11.40
app/models/concerns/audit_trackable.rb 62.86 % 65 35 22 13 3.34
app/models/concerns/soft_deletable.rb 57.58 % 72 33 19 14 5.55
app/models/concerns/uuid_identifiable.rb 86.67 % 31 15 13 2 5.00
app/models/content/document.rb 0.00 % 378 280 0 280 0.00
app/models/content/document_version.rb 0.00 % 176 128 0 128 0.00
app/models/content/folder.rb 0.00 % 228 174 0 174 0.00
app/models/current.rb 81.82 % 18 11 9 2 2.82
app/models/documents/folder.rb 0.00 % 82 58 0 58 0.00
app/models/documents/folder_document.rb 0.00 % 34 21 0 21 0.00
app/models/health_check.rb 0.00 % 34 24 0 24 0.00
app/models/hr/employee.rb 85.56 % 357 180 154 26 1.56
app/models/hr/employment_certification_request.rb 0.00 % 311 218 0 218 0.00
app/models/hr/vacation_request.rb 80.09 % 421 216 173 43 1.22
app/models/identity/jwt_denylist.rb 77.78 % 34 18 14 4 0.78
app/models/identity/organization.rb 85.71 % 92 56 48 8 1.80
app/models/identity/permission.rb 0.00 % 91 61 0 61 0.00
app/models/identity/role.rb 60.82 % 294 97 59 38 0.61
app/models/identity/user.rb 73.55 % 231 121 89 32 1.26
app/models/identity/user_signature.rb 0.00 % 165 107 0 107 0.00
app/models/legal/contract.rb 0.00 % 454 345 0 345 0.00
app/models/legal/contract_approval.rb 0.00 % 99 75 0 75 0.00
app/models/legal/third_party.rb 0.00 % 221 157 0 157 0.00
app/models/legal/third_party_type.rb 0.00 % 67 47 0 47 0.00
app/models/retention/legal_hold.rb 0.00 % 234 157 0 157 0.00
app/models/retention/retention_policy.rb 0.00 % 206 149 0 149 0.00
app/models/retention/retention_schedule.rb 0.00 % 301 201 0 201 0.00
app/models/templates/generated_document.rb 0.00 % 586 405 0 405 0.00
app/models/templates/signatory_type.rb 0.00 % 123 90 0 90 0.00
app/models/templates/template.rb 0.00 % 533 370 0 370 0.00
app/models/templates/template_signatory.rb 0.00 % 221 161 0 161 0.00
app/models/templates/variable_mapping.rb 0.00 % 349 262 0 262 0.00
app/models/workflow/workflow_definition.rb 0.00 % 195 137 0 137 0.00
app/models/workflow/workflow_instance.rb 0.00 % 322 217 0 217 0.00
app/models/workflow/workflow_task.rb 0.00 % 302 203 0 203 0.00
app/policies/application_policy.rb 0.00 % 95 68 0 68 0.00
app/policies/content/document_policy.rb 0.00 % 55 42 0 42 0.00
app/policies/content/folder_policy.rb 0.00 % 50 38 0 38 0.00
app/policies/hr/employee_policy.rb 0.00 % 77 59 0 59 0.00
app/policies/hr/employment_certification_request_policy.rb 0.00 % 77 58 0 58 0.00
app/policies/hr/vacation_request_policy.rb 0.00 % 78 58 0 58 0.00
app/policies/identity/user_policy.rb 0.00 % 37 30 0 30 0.00
app/policies/legal/contract_policy.rb 0.00 % 141 108 0 108 0.00
app/policies/legal/third_party_policy.rb 0.00 % 45 35 0 35 0.00
app/policies/legal/third_party_type_policy.rb 0.00 % 49 38 0 38 0.00
app/policies/settings_policy.rb 0.00 % 11 8 0 8 0.00
app/policies/templates/generated_document_policy.rb 0.00 % 83 59 0 59 0.00
app/services/audit/log_event_service.rb 0.00 % 33 29 0 29 0.00
app/services/base_service.rb 0.00 % 69 53 0 53 0.00
app/services/health_check_service.rb 0.00 % 48 38 0 38 0.00
app/services/hr/employee_account_service.rb 0.00 % 111 81 0 81 0.00
app/services/hr/hr_service.rb 0.00 % 287 214 0 214 0.00
app/services/hr/vacation_calculator.rb 0.00 % 131 83 0 83 0.00
app/services/retention/retention_service.rb 0.00 % 216 150 0 150 0.00
app/services/search/adapters/elasticsearch_adapter.rb 0.00 % 120 83 0 83 0.00
app/services/search/adapters/mongo_adapter.rb 0.00 % 241 159 0 159 0.00
app/services/search/base_adapter.rb 0.00 % 114 47 0 47 0.00
app/services/search/query.rb 0.00 % 172 136 0 136 0.00
app/services/search/results.rb 0.00 % 123 94 0 94 0.00
app/services/search/search_service.rb 0.00 % 167 95 0 95 0.00
app/services/service_result.rb 0.00 % 41 32 0 32 0.00
app/services/templates/document_generator_service.rb 0.00 % 233 171 0 171 0.00
app/services/templates/pdf_signature_service.rb 0.00 % 254 162 0 162 0.00
app/services/templates/robust_document_generator_service.rb 0.00 % 992 736 0 736 0.00
app/services/templates/signature_renderer_service.rb 0.00 % 122 90 0 90 0.00
app/services/templates/template_parser_service.rb 0.00 % 66 48 0 48 0.00
app/services/templates/variable_normalizer.rb 0.00 % 109 60 0 60 0.00
app/services/templates/variable_resolver_service.rb 0.00 % 698 588 0 588 0.00
app/services/workflow/workflow_service.rb 0.00 % 200 114 0 114 0.00
lib/tasks/regenerate_previews.rb 0.00 % 48 38 0 38 0.00

Controllers ( 0.0% covered at 0.0 hits/line )

33 files in total.
5080 relevant lines, 0 lines covered and 5080 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/controllers/api/v1/admin/departments_controller.rb 0.00 % 165 115 0 115 0.00
app/controllers/api/v1/admin/settings_controller.rb 0.00 % 103 88 0 88 0.00
app/controllers/api/v1/admin/signatory_types_controller.rb 0.00 % 175 136 0 136 0.00
app/controllers/api/v1/admin/template_signatories_controller.rb 0.00 % 167 134 0 134 0.00
app/controllers/api/v1/admin/templates_controller.rb 0.00 % 440 340 0 340 0.00
app/controllers/api/v1/admin/users_controller.rb 0.00 % 265 199 0 199 0.00
app/controllers/api/v1/admin/variable_mappings_controller.rb 0.00 % 515 374 0 374 0.00
app/controllers/api/v1/auth/passwords_controller.rb 0.00 % 118 87 0 87 0.00
app/controllers/api/v1/auth/profiles_controller.rb 0.00 % 70 59 0 59 0.00
app/controllers/api/v1/auth/sessions_controller.rb 0.00 % 127 105 0 105 0.00
app/controllers/api/v1/auth/signatures_controller.rb 0.00 % 170 135 0 135 0.00
app/controllers/api/v1/base_controller.rb 0.00 % 67 49 0 49 0.00
app/controllers/api/v1/content/documents_controller.rb 0.00 % 170 134 0 134 0.00
app/controllers/api/v1/content/folders_controller.rb 0.00 % 106 84 0 84 0.00
app/controllers/api/v1/content/versions_controller.rb 0.00 % 111 87 0 87 0.00
app/controllers/api/v1/documents_controller.rb 0.00 % 253 191 0 191 0.00
app/controllers/api/v1/folders_controller.rb 0.00 % 187 148 0 148 0.00
app/controllers/api/v1/health_controller.rb 0.00 % 27 23 0 23 0.00
app/controllers/api/v1/hr/approvals_controller.rb 0.00 % 259 212 0 212 0.00
app/controllers/api/v1/hr/certifications_controller.rb 0.00 % 452 354 0 354 0.00
app/controllers/api/v1/hr/dashboard_controller.rb 0.00 % 36 26 0 26 0.00
app/controllers/api/v1/hr/employees_controller.rb 0.00 % 473 380 0 380 0.00
app/controllers/api/v1/hr/vacations_controller.rb 0.00 % 444 336 0 336 0.00
app/controllers/api/v1/legal/contract_approvals_controller.rb 0.00 % 313 247 0 247 0.00
app/controllers/api/v1/legal/contracts_controller.rb 0.00 % 535 428 0 428 0.00
app/controllers/api/v1/legal/dashboard_controller.rb 0.00 % 102 90 0 90 0.00
app/controllers/api/v1/legal/third_parties_controller.rb 0.00 % 192 155 0 155 0.00
app/controllers/api/v1/legal/third_party_types_controller.rb 0.00 % 124 105 0 105 0.00
app/controllers/api/v1/search_controller.rb 0.00 % 79 66 0 66 0.00
app/controllers/api/v1/templates_controller.rb 0.00 % 121 96 0 96 0.00
app/controllers/api/v1/users_controller.rb 0.00 % 50 41 0 41 0.00
app/controllers/application_controller.rb 0.00 % 64 51 0 51 0.00
app/controllers/frontend_controller.rb 0.00 % 7 5 0 5 0.00

Channels ( 0.0% covered at 0.0 hits/line )

2 files in total.
8 relevant lines, 0 lines covered and 8 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/channels/application_cable/channel.rb 0.00 % 6 4 0 4 0.00
app/channels/application_cable/connection.rb 0.00 % 6 4 0 4 0.00

Models ( 14.12% covered at 0.55 hits/line )

35 files in total.
4957 relevant lines, 700 lines covered and 4257 lines missed. ( 14.12% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/models/audit/audit_event.rb 78.13 % 344 128 100 28 11.40
app/models/concerns/audit_trackable.rb 62.86 % 65 35 22 13 3.34
app/models/concerns/soft_deletable.rb 57.58 % 72 33 19 14 5.55
app/models/concerns/uuid_identifiable.rb 86.67 % 31 15 13 2 5.00
app/models/content/document.rb 0.00 % 378 280 0 280 0.00
app/models/content/document_version.rb 0.00 % 176 128 0 128 0.00
app/models/content/folder.rb 0.00 % 228 174 0 174 0.00
app/models/current.rb 81.82 % 18 11 9 2 2.82
app/models/documents/folder.rb 0.00 % 82 58 0 58 0.00
app/models/documents/folder_document.rb 0.00 % 34 21 0 21 0.00
app/models/health_check.rb 0.00 % 34 24 0 24 0.00
app/models/hr/employee.rb 85.56 % 357 180 154 26 1.56
app/models/hr/employment_certification_request.rb 0.00 % 311 218 0 218 0.00
app/models/hr/vacation_request.rb 80.09 % 421 216 173 43 1.22
app/models/identity/jwt_denylist.rb 77.78 % 34 18 14 4 0.78
app/models/identity/organization.rb 85.71 % 92 56 48 8 1.80
app/models/identity/permission.rb 0.00 % 91 61 0 61 0.00
app/models/identity/role.rb 60.82 % 294 97 59 38 0.61
app/models/identity/user.rb 73.55 % 231 121 89 32 1.26
app/models/identity/user_signature.rb 0.00 % 165 107 0 107 0.00
app/models/legal/contract.rb 0.00 % 454 345 0 345 0.00
app/models/legal/contract_approval.rb 0.00 % 99 75 0 75 0.00
app/models/legal/third_party.rb 0.00 % 221 157 0 157 0.00
app/models/legal/third_party_type.rb 0.00 % 67 47 0 47 0.00
app/models/retention/legal_hold.rb 0.00 % 234 157 0 157 0.00
app/models/retention/retention_policy.rb 0.00 % 206 149 0 149 0.00
app/models/retention/retention_schedule.rb 0.00 % 301 201 0 201 0.00
app/models/templates/generated_document.rb 0.00 % 586 405 0 405 0.00
app/models/templates/signatory_type.rb 0.00 % 123 90 0 90 0.00
app/models/templates/template.rb 0.00 % 533 370 0 370 0.00
app/models/templates/template_signatory.rb 0.00 % 221 161 0 161 0.00
app/models/templates/variable_mapping.rb 0.00 % 349 262 0 262 0.00
app/models/workflow/workflow_definition.rb 0.00 % 195 137 0 137 0.00
app/models/workflow/workflow_instance.rb 0.00 % 322 217 0 217 0.00
app/models/workflow/workflow_task.rb 0.00 % 302 203 0 203 0.00

Mailers ( 0.0% covered at 0.0 hits/line )

1 files in total.
4 relevant lines, 0 lines covered and 4 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/mailers/application_mailer.rb 0.00 % 6 4 0 4 0.00

Helpers ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line

Jobs ( 0.0% covered at 0.0 hits/line )

6 files in total.
673 relevant lines, 0 lines covered and 673 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/jobs/application_job.rb 0.00 % 39 25 0 25 0.00
app/jobs/retention_notification_job.rb 0.00 % 272 196 0 196 0.00
app/jobs/retention_processor_job.rb 0.00 % 181 132 0 132 0.00
app/jobs/sla_check_job.rb 0.00 % 59 37 0 37 0.00
app/jobs/sla_warning_job.rb 0.00 % 33 20 0 20 0.00
app/jobs/workflow_notification_job.rb 0.00 % 351 263 0 263 0.00

Libraries ( 0.0% covered at 0.0 hits/line )

1 files in total.
38 relevant lines, 0 lines covered and 38 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/tasks/regenerate_previews.rb 0.00 % 48 38 0 38 0.00

Services ( 0.0% covered at 0.0 hits/line )

22 files in total.
3263 relevant lines, 0 lines covered and 3263 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/services/audit/log_event_service.rb 0.00 % 33 29 0 29 0.00
app/services/base_service.rb 0.00 % 69 53 0 53 0.00
app/services/health_check_service.rb 0.00 % 48 38 0 38 0.00
app/services/hr/employee_account_service.rb 0.00 % 111 81 0 81 0.00
app/services/hr/hr_service.rb 0.00 % 287 214 0 214 0.00
app/services/hr/vacation_calculator.rb 0.00 % 131 83 0 83 0.00
app/services/retention/retention_service.rb 0.00 % 216 150 0 150 0.00
app/services/search/adapters/elasticsearch_adapter.rb 0.00 % 120 83 0 83 0.00
app/services/search/adapters/mongo_adapter.rb 0.00 % 241 159 0 159 0.00
app/services/search/base_adapter.rb 0.00 % 114 47 0 47 0.00
app/services/search/query.rb 0.00 % 172 136 0 136 0.00
app/services/search/results.rb 0.00 % 123 94 0 94 0.00
app/services/search/search_service.rb 0.00 % 167 95 0 95 0.00
app/services/service_result.rb 0.00 % 41 32 0 32 0.00
app/services/templates/document_generator_service.rb 0.00 % 233 171 0 171 0.00
app/services/templates/pdf_signature_service.rb 0.00 % 254 162 0 162 0.00
app/services/templates/robust_document_generator_service.rb 0.00 % 992 736 0 736 0.00
app/services/templates/signature_renderer_service.rb 0.00 % 122 90 0 90 0.00
app/services/templates/template_parser_service.rb 0.00 % 66 48 0 48 0.00
app/services/templates/variable_normalizer.rb 0.00 % 109 60 0 60 0.00
app/services/templates/variable_resolver_service.rb 0.00 % 698 588 0 588 0.00
app/services/workflow/workflow_service.rb 0.00 % 200 114 0 114 0.00

Policies ( 0.0% covered at 0.0 hits/line )

12 files in total.
601 relevant lines, 0 lines covered and 601 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/policies/application_policy.rb 0.00 % 95 68 0 68 0.00
app/policies/content/document_policy.rb 0.00 % 55 42 0 42 0.00
app/policies/content/folder_policy.rb 0.00 % 50 38 0 38 0.00
app/policies/hr/employee_policy.rb 0.00 % 77 59 0 59 0.00
app/policies/hr/employment_certification_request_policy.rb 0.00 % 77 58 0 58 0.00
app/policies/hr/vacation_request_policy.rb 0.00 % 78 58 0 58 0.00
app/policies/identity/user_policy.rb 0.00 % 37 30 0 30 0.00
app/policies/legal/contract_policy.rb 0.00 % 141 108 0 108 0.00
app/policies/legal/third_party_policy.rb 0.00 % 45 35 0 35 0.00
app/policies/legal/third_party_type_policy.rb 0.00 % 49 38 0 38 0.00
app/policies/settings_policy.rb 0.00 % 11 8 0 8 0.00
app/policies/templates/generated_document_policy.rb 0.00 % 83 59 0 59 0.00

Validators ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line

Lib ( 0.0% covered at 0.0 hits/line )

1 files in total.
38 relevant lines, 0 lines covered and 38 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/tasks/regenerate_previews.rb 0.00 % 48 38 0 38 0.00

app/channels/application_cable/channel.rb

0.0% lines covered

4 relevant lines. 0 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. module ApplicationCable
  3. class Channel < ActionCable::Channel::Base
  4. end
  5. end

app/channels/application_cable/connection.rb

0.0% lines covered

4 relevant lines. 0 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. module ApplicationCable
  3. class Connection < ActionCable::Connection::Base
  4. end
  5. end

app/controllers/api/v1/admin/departments_controller.rb

0.0% lines covered

115 relevant lines. 0 lines covered and 115 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class DepartmentsController < BaseController
  6. before_action :require_admin_or_hr
  7. # GET /api/v1/admin/departments
  8. def index
  9. # Get unique departments from employees
  10. employee_departments = ::Hr::Employee.where(organization_id: current_organization.id)
  11. .distinct(:department)
  12. .compact
  13. # Get configured departments from organization settings
  14. configured_departments = current_organization.settings&.dig("departments") || []
  15. # Merge both lists (configured + any that exist in employees)
  16. all_departments = (configured_departments + employee_departments).uniq.sort
  17. # Build department stats
  18. departments_with_stats = all_departments.map do |dept|
  19. employee_count = ::Hr::Employee.where(organization_id: current_organization.id, department: dept).count
  20. {
  21. id: dept.parameterize,
  22. name: dept,
  23. code: dept.parameterize.upcase.gsub("-", "_"),
  24. employee_count: employee_count,
  25. active: configured_departments.include?(dept) || employee_count > 0,
  26. is_configured: configured_departments.include?(dept)
  27. }
  28. end
  29. render json: {
  30. data: departments_with_stats,
  31. meta: {
  32. total: departments_with_stats.count,
  33. total_employees: ::Hr::Employee.where(organization_id: current_organization.id).count
  34. }
  35. }
  36. end
  37. # POST /api/v1/admin/departments
  38. def create
  39. name = params[:department][:name]&.strip
  40. if name.blank?
  41. return render json: { error: "El nombre del departamento es requerido" }, status: :unprocessable_entity
  42. end
  43. # Get current departments
  44. departments = current_organization.settings&.dig("departments") || []
  45. if departments.include?(name)
  46. return render json: { error: "El departamento ya existe" }, status: :unprocessable_entity
  47. end
  48. # Add new department
  49. departments << name
  50. current_organization.settings ||= {}
  51. current_organization.settings["departments"] = departments.sort
  52. current_organization.save!
  53. render json: {
  54. data: {
  55. id: name.parameterize,
  56. name: name,
  57. code: name.parameterize.upcase.gsub("-", "_"),
  58. employee_count: 0,
  59. active: true,
  60. is_configured: true
  61. },
  62. message: "Departamento creado correctamente"
  63. }, status: :created
  64. end
  65. # PATCH /api/v1/admin/departments/:id
  66. def update
  67. old_name = params[:id].gsub("-", " ").titleize
  68. new_name = params[:department][:name]&.strip
  69. if new_name.blank?
  70. return render json: { error: "El nombre del departamento es requerido" }, status: :unprocessable_entity
  71. end
  72. departments = current_organization.settings&.dig("departments") || []
  73. # Find the original department (case-insensitive)
  74. original = departments.find { |d| d.parameterize == params[:id] }
  75. unless original
  76. return render json: { error: "Departamento no encontrado" }, status: :not_found
  77. end
  78. # Update in settings
  79. departments = departments.map { |d| d == original ? new_name : d }
  80. current_organization.settings["departments"] = departments.sort
  81. current_organization.save!
  82. # Update employees with this department
  83. ::Hr::Employee.where(organization_id: current_organization.id, department: original)
  84. .update_all(department: new_name)
  85. employee_count = ::Hr::Employee.where(organization_id: current_organization.id, department: new_name).count
  86. render json: {
  87. data: {
  88. id: new_name.parameterize,
  89. name: new_name,
  90. code: new_name.parameterize.upcase.gsub("-", "_"),
  91. employee_count: employee_count,
  92. active: true,
  93. is_configured: true
  94. },
  95. message: "Departamento actualizado correctamente"
  96. }
  97. end
  98. # DELETE /api/v1/admin/departments/:id
  99. def destroy
  100. departments = current_organization.settings&.dig("departments") || []
  101. # Find the department
  102. department = departments.find { |d| d.parameterize == params[:id] }
  103. unless department
  104. return render json: { error: "Departamento no encontrado" }, status: :not_found
  105. end
  106. # Check if has employees
  107. employee_count = ::Hr::Employee.where(organization_id: current_organization.id, department: department).count
  108. if employee_count > 0
  109. return render json: {
  110. error: "No se puede eliminar un departamento con empleados asignados",
  111. employee_count: employee_count
  112. }, status: :unprocessable_entity
  113. end
  114. # Remove from settings
  115. departments.delete(department)
  116. current_organization.settings["departments"] = departments
  117. current_organization.save!
  118. render json: { message: "Departamento eliminado correctamente" }
  119. end
  120. # POST /api/v1/admin/departments/:id/toggle_active
  121. def toggle_active
  122. # For now, just return success - departments are always active if configured
  123. render json: { message: "Estado actualizado" }
  124. end
  125. private
  126. def require_admin_or_hr
  127. unless current_user.admin? || current_user.has_role?("hr") || current_user.has_role?("hr_manager")
  128. render json: { error: "No autorizado" }, status: :forbidden
  129. end
  130. end
  131. end
  132. end
  133. end
  134. end

app/controllers/api/v1/admin/settings_controller.rb

0.0% lines covered

88 relevant lines. 0 lines covered and 88 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class SettingsController < BaseController
  6. before_action :set_organization
  7. def show
  8. authorize :settings, :show?
  9. render json: {
  10. data: organization_settings
  11. }, status: :ok
  12. end
  13. def update
  14. authorize :settings, :update?
  15. if @organization.update(organization_params)
  16. render json: {
  17. message: "Configuración actualizada correctamente",
  18. data: organization_settings
  19. }, status: :ok
  20. else
  21. render json: {
  22. error: "Error al actualizar configuración",
  23. errors: @organization.errors.full_messages
  24. }, status: :unprocessable_entity
  25. end
  26. end
  27. private
  28. def set_organization
  29. @organization = current_organization
  30. render_error("Organización no encontrada", status: :not_found) unless @organization
  31. end
  32. def organization_params
  33. params.require(:settings).permit(
  34. :name, :legal_name, :tax_id, :address, :city, :country,
  35. :phone, :email, :website, :logo_url,
  36. :vacation_days_per_year, :vacation_accrual_policy,
  37. :max_vacation_carryover, :probation_period_months,
  38. :max_file_size_mb, :document_retention_years,
  39. :session_timeout_minutes, :password_min_length,
  40. :password_require_uppercase, :password_require_number,
  41. :password_require_special, :max_login_attempts,
  42. allowed_file_types: []
  43. )
  44. end
  45. def organization_settings
  46. {
  47. # System info
  48. system: {
  49. app_name: "VALKYRIA ECM",
  50. version: "1.0.0",
  51. environment: Rails.env
  52. },
  53. # Organization details
  54. organization: {
  55. id: @organization.uuid,
  56. name: @organization.name,
  57. legal_name: @organization.legal_name,
  58. tax_id: @organization.tax_id,
  59. address: @organization.address,
  60. city: @organization.city,
  61. country: @organization.country,
  62. phone: @organization.phone,
  63. email: @organization.email,
  64. website: @organization.website,
  65. logo_url: @organization.logo_url
  66. },
  67. # HR Settings
  68. hr: {
  69. vacation_days_per_year: @organization.vacation_days_per_year,
  70. vacation_accrual_policy: @organization.vacation_accrual_policy,
  71. max_vacation_carryover: @organization.max_vacation_carryover,
  72. probation_period_months: @organization.probation_period_months
  73. },
  74. # Document Settings
  75. documents: {
  76. allowed_file_types: @organization.allowed_file_types,
  77. max_file_size_mb: @organization.max_file_size_mb,
  78. document_retention_years: @organization.document_retention_years
  79. },
  80. # Security Settings
  81. security: {
  82. session_timeout_minutes: @organization.session_timeout_minutes,
  83. password_min_length: @organization.password_min_length,
  84. password_require_uppercase: @organization.password_require_uppercase,
  85. password_require_number: @organization.password_require_number,
  86. password_require_special: @organization.password_require_special,
  87. max_login_attempts: @organization.max_login_attempts
  88. }
  89. }
  90. end
  91. end
  92. end
  93. end
  94. end

app/controllers/api/v1/admin/signatory_types_controller.rb

0.0% lines covered

136 relevant lines. 0 lines covered and 136 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class SignatoryTypesController < BaseController
  6. before_action :ensure_admin_or_hr
  7. before_action :set_signatory_type, only: [:show, :update, :destroy, :toggle_active]
  8. # GET /api/v1/admin/signatory_types
  9. def index
  10. @types = ::Templates::SignatoryType
  11. .for_organization(current_organization)
  12. .ordered
  13. # Filter by active status
  14. if params[:active].present?
  15. @types = params[:active] == "true" ? @types.active : @types.inactive
  16. end
  17. # Filter system vs custom
  18. if params[:type].present?
  19. @types = params[:type] == "system" ? @types.system_types : @types.custom_types
  20. end
  21. render json: {
  22. data: @types.map { |t| type_json(t) },
  23. meta: {
  24. total: @types.count
  25. }
  26. }
  27. end
  28. # GET /api/v1/admin/signatory_types/:id
  29. def show
  30. render json: { data: type_json(@signatory_type) }
  31. end
  32. # POST /api/v1/admin/signatory_types
  33. def create
  34. @signatory_type = ::Templates::SignatoryType.new(type_params)
  35. @signatory_type.organization = current_organization
  36. @signatory_type.created_by = current_user
  37. @signatory_type.is_system = false
  38. if @signatory_type.save
  39. render json: {
  40. data: type_json(@signatory_type),
  41. message: "Tipo de firmante creado exitosamente"
  42. }, status: :created
  43. else
  44. render json: {
  45. error: "Error al crear tipo de firmante",
  46. errors: @signatory_type.errors.full_messages
  47. }, status: :unprocessable_content
  48. end
  49. end
  50. # PATCH /api/v1/admin/signatory_types/:id
  51. def update
  52. if @signatory_type.system?
  53. return render json: {
  54. error: "No se pueden modificar tipos de firmante del sistema"
  55. }, status: :forbidden
  56. end
  57. if @signatory_type.update(type_params)
  58. render json: {
  59. data: type_json(@signatory_type),
  60. message: "Tipo de firmante actualizado exitosamente"
  61. }
  62. else
  63. render json: {
  64. error: "Error al actualizar tipo de firmante",
  65. errors: @signatory_type.errors.full_messages
  66. }, status: :unprocessable_content
  67. end
  68. end
  69. # DELETE /api/v1/admin/signatory_types/:id
  70. def destroy
  71. if @signatory_type.system?
  72. return render json: {
  73. error: "No se pueden eliminar tipos de firmante del sistema"
  74. }, status: :forbidden
  75. end
  76. if @signatory_type.in_use?
  77. return render json: {
  78. error: "No se puede eliminar este tipo de firmante porque está siendo usado en #{@signatory_type.usage_count} plantilla(s)"
  79. }, status: :conflict
  80. end
  81. @signatory_type.destroy
  82. render json: { message: "Tipo de firmante eliminado exitosamente" }
  83. end
  84. # POST /api/v1/admin/signatory_types/:id/toggle_active
  85. def toggle_active
  86. @signatory_type.toggle_active!
  87. render json: {
  88. data: type_json(@signatory_type),
  89. message: @signatory_type.active? ? "Tipo de firmante activado" : "Tipo de firmante desactivado"
  90. }
  91. end
  92. # POST /api/v1/admin/signatory_types/seed_system
  93. def seed_system
  94. ::Templates::SignatoryType.seed_system_types!
  95. render json: {
  96. message: "Tipos de firmante del sistema creados exitosamente",
  97. count: ::Templates::SignatoryType.system_types.count
  98. }
  99. end
  100. # POST /api/v1/admin/signatory_types/reorder
  101. def reorder
  102. return render json: { error: "Se requiere lista de IDs" }, status: :bad_request unless params[:ids].present?
  103. params[:ids].each_with_index do |uuid, index|
  104. type = ::Templates::SignatoryType.find_by(uuid: uuid)
  105. type&.update!(position: index)
  106. end
  107. render json: { message: "Orden actualizado" }
  108. end
  109. private
  110. def ensure_admin_or_hr
  111. return if current_user.admin? || current_user.has_role?("hr")
  112. render json: {
  113. error: "Acceso denegado. Se requieren privilegios de administrador o HR."
  114. }, status: :forbidden
  115. end
  116. def set_signatory_type
  117. @signatory_type = ::Templates::SignatoryType.find_by(uuid: params[:id])
  118. return if @signatory_type
  119. render json: { error: "Tipo de firmante no encontrado" }, status: :not_found
  120. end
  121. def type_params
  122. params.require(:signatory_type).permit(
  123. :name,
  124. :code,
  125. :description,
  126. :active,
  127. :position
  128. )
  129. end
  130. def type_json(type)
  131. {
  132. id: type.uuid,
  133. name: type.name,
  134. code: type.code,
  135. description: type.description,
  136. is_system: type.is_system,
  137. active: type.active,
  138. position: type.position,
  139. in_use: type.in_use?,
  140. usage_count: type.usage_count,
  141. created_at: type.created_at.iso8601
  142. }
  143. end
  144. end
  145. end
  146. end
  147. end

app/controllers/api/v1/admin/template_signatories_controller.rb

0.0% lines covered

134 relevant lines. 0 lines covered and 134 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class TemplateSignatoriesController < BaseController
  6. before_action :ensure_admin_or_hr
  7. before_action :set_template
  8. before_action :set_signatory, only: [:show, :update, :destroy]
  9. # GET /api/v1/admin/templates/:template_id/signatories
  10. def index
  11. @signatories = @template.signatories.by_position
  12. render json: {
  13. data: @signatories.map { |s| signatory_json(s) },
  14. meta: {
  15. total: @signatories.count,
  16. roles: ::Templates::TemplateSignatory::ROLE_LABELS
  17. }
  18. }
  19. end
  20. # GET /api/v1/admin/templates/:template_id/signatories/:id
  21. def show
  22. render json: { data: signatory_json(@signatory) }
  23. end
  24. # POST /api/v1/admin/templates/:template_id/signatories
  25. def create
  26. @signatory = @template.signatories.build(signatory_params)
  27. # Set position to end if not specified
  28. @signatory.position ||= @template.signatories.count
  29. if @signatory.save
  30. render json: {
  31. data: signatory_json(@signatory),
  32. message: "Firmante agregado exitosamente"
  33. }, status: :created
  34. else
  35. render json: {
  36. error: "Error al agregar firmante",
  37. errors: @signatory.errors.full_messages
  38. }, status: :unprocessable_content
  39. end
  40. end
  41. # PATCH /api/v1/admin/templates/:template_id/signatories/:id
  42. def update
  43. if @signatory.update(signatory_params)
  44. render json: {
  45. data: signatory_json(@signatory),
  46. message: "Firmante actualizado exitosamente"
  47. }
  48. else
  49. render json: {
  50. error: "Error al actualizar firmante",
  51. errors: @signatory.errors.full_messages
  52. }, status: :unprocessable_content
  53. end
  54. end
  55. # DELETE /api/v1/admin/templates/:template_id/signatories/:id
  56. def destroy
  57. @signatory.destroy
  58. render json: { message: "Firmante eliminado exitosamente" }
  59. end
  60. # POST /api/v1/admin/templates/:template_id/signatories/reorder
  61. def reorder
  62. return render json: { error: "Se requiere lista de IDs" }, status: :bad_request unless params[:ids].present?
  63. params[:ids].each_with_index do |uuid, index|
  64. next if uuid.blank?
  65. signatory = @template.signatories.where(uuid: uuid).first
  66. signatory&.update!(position: index)
  67. end
  68. render json: {
  69. data: @template.signatories.by_position.map { |s| signatory_json(s) },
  70. message: "Orden actualizado"
  71. }
  72. end
  73. private
  74. def ensure_admin_or_hr
  75. return if current_user.admin? || current_user.has_role?("hr")
  76. render json: {
  77. error: "Acceso denegado. Se requieren privilegios de administrador o HR."
  78. }, status: :forbidden
  79. end
  80. def set_template
  81. return render json: { error: "ID de template requerido" }, status: :bad_request if params[:template_id].blank?
  82. @template = ::Templates::Template.where(
  83. uuid: params[:template_id],
  84. organization_id: current_organization.id
  85. ).first
  86. return if @template
  87. render json: { error: "Template no encontrado" }, status: :not_found
  88. end
  89. def set_signatory
  90. return render json: { error: "ID de firmante requerido" }, status: :bad_request if params[:id].blank?
  91. @signatory = @template.signatories.where(uuid: params[:id]).first
  92. return if @signatory
  93. render json: { error: "Firmante no encontrado" }, status: :not_found
  94. end
  95. def signatory_params
  96. params.require(:signatory).permit(
  97. :role,
  98. :signatory_type_code,
  99. :label,
  100. :position,
  101. :required,
  102. :placeholder_text,
  103. :page_number,
  104. :x_position,
  105. :y_position,
  106. :width,
  107. :height,
  108. :date_position,
  109. :show_label,
  110. :show_signer_name,
  111. :custom_user_id,
  112. :custom_email
  113. )
  114. end
  115. def signatory_json(signatory)
  116. {
  117. id: signatory.uuid,
  118. role: signatory.role,
  119. signatory_type_code: signatory.signatory_type_code,
  120. effective_code: signatory.effective_code,
  121. role_label: signatory.role_label,
  122. label: signatory.label,
  123. position: signatory.position,
  124. required: signatory.required,
  125. placeholder_text: signatory.placeholder_text,
  126. page_number: signatory.page_number,
  127. x_position: signatory.x_position,
  128. y_position: signatory.y_position,
  129. width: signatory.width,
  130. height: signatory.height,
  131. date_position: signatory.date_position || "right",
  132. show_label: signatory.show_label.nil? ? true : signatory.show_label,
  133. show_signer_name: signatory.show_signer_name || false,
  134. custom_user_id: signatory.custom_user_id&.to_s,
  135. custom_email: signatory.custom_email,
  136. created_at: signatory.created_at.iso8601
  137. }
  138. end
  139. end
  140. end
  141. end
  142. end

app/controllers/api/v1/admin/templates_controller.rb

0.0% lines covered

340 relevant lines. 0 lines covered and 340 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class TemplatesController < BaseController
  6. before_action :ensure_admin_or_hr
  7. before_action :set_template, only: [:show, :update, :destroy, :activate, :archive, :duplicate, :reassign_mappings, :download, :preview]
  8. # GET /api/v1/admin/templates
  9. def index
  10. @templates = ::Templates::Template
  11. .for_organization(current_organization)
  12. .order(created_at: :desc)
  13. # Filter by status
  14. @templates = @templates.where(status: params[:status]) if params[:status].present?
  15. # Filter by module
  16. @templates = @templates.by_module(params[:module_type]) if params[:module_type].present?
  17. # Filter by main category
  18. @templates = @templates.by_main_category(params[:main_category]) if params[:main_category].present?
  19. # Filter by subcategory
  20. @templates = @templates.where(category: params[:category]) if params[:category].present?
  21. # Search by name
  22. if params[:q].present?
  23. @templates = @templates.where(name: /#{Regexp.escape(params[:q])}/i)
  24. end
  25. render json: {
  26. data: @templates.map { |t| template_json(t) },
  27. meta: {
  28. total: @templates.count,
  29. categories: ::Templates::Template::CATEGORIES,
  30. statuses: ::Templates::Template::STATUSES
  31. }
  32. }
  33. end
  34. # GET /api/v1/admin/templates/:id
  35. def show
  36. render json: {
  37. data: template_json(@template, detailed: true)
  38. }
  39. end
  40. # POST /api/v1/admin/templates
  41. def create
  42. @template = ::Templates::Template.new(template_params)
  43. @template.organization = current_organization
  44. @template.created_by = current_user
  45. if @template.save
  46. # Handle file upload if present
  47. handle_file_upload if params[:file].present?
  48. render json: {
  49. data: template_json(@template),
  50. message: "Template creado exitosamente"
  51. }, status: :created
  52. else
  53. render json: {
  54. error: "Error al crear template",
  55. errors: @template.errors.full_messages
  56. }, status: :unprocessable_content
  57. end
  58. end
  59. # PATCH /api/v1/admin/templates/:id
  60. def update
  61. if @template.update(template_params)
  62. # Handle file upload if present
  63. handle_file_upload if params[:file].present?
  64. render json: {
  65. data: template_json(@template),
  66. message: "Template actualizado exitosamente"
  67. }
  68. else
  69. render json: {
  70. error: "Error al actualizar template",
  71. errors: @template.errors.full_messages
  72. }, status: :unprocessable_content
  73. end
  74. end
  75. # DELETE /api/v1/admin/templates/:id
  76. def destroy
  77. if @template.generated_documents.completed.any?
  78. render json: {
  79. error: "No se puede eliminar un template con documentos generados"
  80. }, status: :unprocessable_content
  81. else
  82. @template.destroy
  83. render json: { message: "Template eliminado exitosamente" }
  84. end
  85. end
  86. # POST /api/v1/admin/templates/:id/activate
  87. def activate
  88. @template.activate!
  89. render json: {
  90. data: template_json(@template),
  91. message: "Template activado exitosamente"
  92. }
  93. rescue ::Templates::Template::InvalidStateError => e
  94. render json: { error: e.message }, status: :unprocessable_content
  95. end
  96. # POST /api/v1/admin/templates/:id/archive
  97. def archive
  98. @template.archive!
  99. render json: {
  100. data: template_json(@template),
  101. message: "Template archivado exitosamente"
  102. }
  103. end
  104. # POST /api/v1/admin/templates/:id/duplicate
  105. def duplicate
  106. new_template = @template.duplicate!
  107. render json: {
  108. data: template_json(new_template),
  109. message: "Template duplicado exitosamente"
  110. }, status: :created
  111. end
  112. # GET /api/v1/admin/templates/categories
  113. def categories
  114. modules = ::Templates::Template::MODULES.map do |key, config|
  115. { value: key, label: config[:label], icon: config[:icon] }
  116. end
  117. main_categories = ::Templates::Template::MAIN_CATEGORIES.map do |key, label|
  118. { value: key, label: label, module: ::Templates::Template::CATEGORY_TO_MODULE[key] }
  119. end
  120. subcategories = ::Templates::Template::SUBCATEGORIES.map do |key, config|
  121. { value: key, label: config[:label], main_category: config[:main] }
  122. end
  123. # Group subcategories by main category for easier frontend consumption
  124. grouped = subcategories.group_by { |s| s[:main_category] }
  125. # Third party types for legal module
  126. third_party_types = ::Legal::ThirdParty::TYPES.map do |type|
  127. { value: type, label: I18n.t("legal.third_party.types.#{type}", default: type.humanize) }
  128. end
  129. render json: {
  130. data: subcategories, # Legacy: flat list of subcategories
  131. modules: modules,
  132. main_categories: main_categories,
  133. subcategories: subcategories,
  134. grouped: grouped,
  135. category_to_module: ::Templates::Template::CATEGORY_TO_MODULE,
  136. third_party_types: third_party_types
  137. }
  138. end
  139. # GET /api/v1/admin/templates/:id/third_party_requirements
  140. def third_party_requirements
  141. set_template
  142. return unless @template
  143. render json: {
  144. data: {
  145. template_id: @template.uuid,
  146. template_name: @template.name,
  147. default_third_party_type: @template.default_third_party_type,
  148. suggested_person_type: @template.suggested_person_type,
  149. required_fields: @template.required_third_party_fields,
  150. uses_third_party: @template.uses_third_party_variables?,
  151. variables: @template.variables,
  152. variables_count: @template.variables&.count || 0
  153. }
  154. }
  155. end
  156. # GET /api/v1/admin/templates/variable_mappings
  157. def variable_mappings
  158. render json: {
  159. data: ::Templates::Template.available_variable_mappings(current_organization),
  160. grouped: ::Templates::Template.grouped_variable_mappings(current_organization).transform_values do |mappings|
  161. mappings.map { |m| { name: m.name, key: m.key, description: m.description } }
  162. end
  163. }
  164. end
  165. # POST /api/v1/admin/templates/:id/upload
  166. def upload
  167. set_template
  168. unless params[:file].present?
  169. return render json: { error: "Archivo requerido" }, status: :bad_request
  170. end
  171. handle_file_upload
  172. render json: {
  173. data: template_json(@template),
  174. message: "Archivo subido exitosamente",
  175. variables: @template.variables
  176. }
  177. end
  178. # POST /api/v1/admin/templates/:id/reassign_mappings
  179. def reassign_mappings
  180. @template.reassign_all_mappings!
  181. render json: {
  182. data: template_json(@template, detailed: true),
  183. message: "Mappings reasignados exitosamente"
  184. }
  185. end
  186. # GET /api/v1/admin/templates/:id/download
  187. def download
  188. unless @template.file_id
  189. return render json: { error: "El template no tiene archivo adjunto" }, status: :not_found
  190. end
  191. file_content = @template.file_content
  192. if file_content
  193. send_data file_content,
  194. filename: @template.file_name || "#{@template.name}.docx",
  195. type: @template.file_content_type || "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  196. disposition: "attachment"
  197. else
  198. render json: { error: "No se pudo obtener el archivo" }, status: :internal_server_error
  199. end
  200. end
  201. # GET /api/v1/admin/templates/:id/preview
  202. def preview
  203. unless @template.file_id
  204. return render json: { error: "El template no tiene archivo adjunto" }, status: :not_found
  205. end
  206. # First, try to use stored PDF preview (works on Heroku without LibreOffice)
  207. if @template.preview_file_id
  208. preview_content = @template.preview_content
  209. if preview_content
  210. return send_data preview_content,
  211. filename: "#{@template.name || 'preview'}.pdf",
  212. type: "application/pdf",
  213. disposition: "inline"
  214. end
  215. end
  216. # If file is already a PDF, serve it directly
  217. if @template.file_name&.end_with?(".pdf")
  218. file_content = @template.file_content
  219. if file_content
  220. return send_data file_content,
  221. filename: @template.file_name,
  222. type: "application/pdf",
  223. disposition: "inline"
  224. end
  225. end
  226. file_content = @template.file_content
  227. unless file_content
  228. return render json: { error: "No se pudo obtener el archivo" }, status: :internal_server_error
  229. end
  230. # Convert Word to PDF using LibreOffice (local development)
  231. temp_dir = Dir.mktmpdir
  232. begin
  233. # Write Word file
  234. docx_path = File.join(temp_dir, "template.docx")
  235. File.binwrite(docx_path, file_content)
  236. # Convert to PDF using LibreOffice
  237. soffice_paths = [
  238. `which soffice`.strip,
  239. "/opt/homebrew/bin/soffice", # macOS Homebrew
  240. "/usr/bin/soffice", # Linux standard
  241. ]
  242. soffice_path = soffice_paths.find { |p| p.present? && File.exist?(p) }
  243. unless soffice_path
  244. # No LibreOffice and no stored preview - return error
  245. return render json: { error: "Preview PDF no disponible. Re-sube el archivo desde un entorno con LibreOffice." }, status: :service_unavailable
  246. end
  247. system(soffice_path, "--headless", "--convert-to", "pdf", "--outdir", temp_dir, docx_path)
  248. pdf_path = File.join(temp_dir, "template.pdf")
  249. unless File.exist?(pdf_path)
  250. return render json: { error: "Error al convertir el documento a PDF" }, status: :internal_server_error
  251. end
  252. pdf_content = File.binread(pdf_path)
  253. # Store this preview for future use
  254. @template.store_pdf_preview!(pdf_content)
  255. @template.save
  256. send_data pdf_content,
  257. filename: "#{@template.name || 'preview'}.pdf",
  258. type: "application/pdf",
  259. disposition: "inline"
  260. ensure
  261. FileUtils.rm_rf(temp_dir)
  262. end
  263. end
  264. private
  265. def ensure_admin_or_hr
  266. return if current_user.admin? || current_user.has_role?("hr")
  267. render json: {
  268. error: "Acceso denegado. Se requieren privilegios de administrador o HR."
  269. }, status: :forbidden
  270. end
  271. def set_template
  272. @template = ::Templates::Template.find_by(
  273. uuid: params[:id],
  274. organization_id: current_organization.id
  275. )
  276. return if @template
  277. render json: { error: "Template no encontrado" }, status: :not_found
  278. end
  279. def template_params
  280. params.require(:template).permit(
  281. :name,
  282. :description,
  283. :module_type,
  284. :main_category,
  285. :category,
  286. :certification_type,
  287. :default_third_party_type,
  288. :preview_scale,
  289. :preview_page_height,
  290. :sequential_signing,
  291. variable_mappings: {}
  292. )
  293. end
  294. def handle_file_upload
  295. file = params[:file]
  296. # Validate file type
  297. unless file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  298. @template.errors.add(:file, "debe ser un documento Word (.docx)")
  299. return
  300. end
  301. # Validate file size (10MB max)
  302. if file.size > 10.megabytes
  303. @template.errors.add(:file, "no debe exceder 10MB")
  304. return
  305. end
  306. @template.attach_file(
  307. file.tempfile,
  308. filename: file.original_filename,
  309. content_type: file.content_type
  310. )
  311. end
  312. def template_json(template, detailed: false)
  313. json = {
  314. id: template.uuid,
  315. name: template.name,
  316. description: template.description,
  317. module_type: template.module_type,
  318. module_type_label: template.module_type_label,
  319. main_category: template.main_category,
  320. main_category_label: template.main_category_label,
  321. category: template.category,
  322. category_label: template.category_label,
  323. status: template.status,
  324. version: template.version,
  325. file_name: template.file_name,
  326. file_size: template.file_size,
  327. variables: template.variables,
  328. signatories_count: template.signatories.count,
  329. certification_type: template.certification_type,
  330. default_third_party_type: template.default_third_party_type,
  331. uses_third_party: template.uses_third_party_variables?,
  332. sequential_signing: template.sequential_signing != false,
  333. preview_scale: template.preview_scale || 0.7,
  334. preview_page_height: template.preview_page_height || 842,
  335. pdf_width: template.pdf_width || 612,
  336. pdf_height: template.pdf_height || 792,
  337. pdf_page_count: template.pdf_page_count || 1,
  338. created_at: template.created_at.iso8601,
  339. updated_at: template.updated_at.iso8601
  340. }
  341. if detailed
  342. json[:variable_mappings] = template.variable_mappings
  343. json[:signatories] = template.signatories.by_position.map { |s| signatory_json(s) }
  344. json[:available_mappings] = ::Templates::Template.available_variable_mappings(current_organization)
  345. json[:required_third_party_fields] = template.required_third_party_fields
  346. json[:suggested_person_type] = template.suggested_person_type
  347. end
  348. json
  349. end
  350. def signatory_json(signatory)
  351. {
  352. id: signatory.uuid,
  353. role: signatory.role,
  354. signatory_type_code: signatory.signatory_type_code,
  355. effective_code: signatory.effective_code,
  356. role_label: signatory.role_label,
  357. label: signatory.label,
  358. position: signatory.position,
  359. required: signatory.required,
  360. placeholder_text: signatory.placeholder_text,
  361. page_number: signatory.page_number,
  362. x_position: signatory.x_position,
  363. y_position: signatory.y_position,
  364. width: signatory.width,
  365. height: signatory.height,
  366. date_position: signatory.date_position || "right",
  367. show_label: signatory.show_label.nil? ? true : signatory.show_label,
  368. show_signer_name: signatory.show_signer_name || false
  369. }
  370. end
  371. end
  372. end
  373. end
  374. end

app/controllers/api/v1/admin/users_controller.rb

0.0% lines covered

199 relevant lines. 0 lines covered and 199 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class UsersController < BaseController
  6. before_action :require_admin
  7. before_action :set_user, only: [:show, :update, :destroy, :toggle_active, :assign_roles]
  8. # GET /api/v1/admin/users
  9. def index
  10. users = policy_scope(::Identity::User).enabled
  11. # Filter by search
  12. if params[:search].present?
  13. search = params[:search].downcase
  14. users = users.or(
  15. { first_name: /#{search}/i },
  16. { last_name: /#{search}/i },
  17. { email: /#{search}/i }
  18. )
  19. end
  20. # Filter by role
  21. if params[:role].present?
  22. role = ::Identity::Role.find_by(name: params[:role])
  23. users = users.where(:role_ids.in => [role&.id].compact) if role
  24. end
  25. # Filter by permission level
  26. if params[:level].present?
  27. level = params[:level].to_i
  28. role_names = ::Identity::Role::ROLE_LEVELS.select { |_, v| v == level }.keys
  29. roles = ::Identity::Role.where(:name.in => role_names)
  30. users = users.where(:role_ids.in => roles.pluck(:id)) if roles.any?
  31. end
  32. # Filter by status
  33. if params[:status].present?
  34. users = params[:status] == 'active' ? users.enabled : users.disabled
  35. end
  36. # Sorting
  37. sort_by = params[:sort_by] || 'created_at'
  38. sort_dir = params[:sort_direction] == 'asc' ? 1 : -1
  39. users = users.order(sort_by => sort_dir)
  40. # Pagination
  41. page = (params[:page] || 1).to_i
  42. per_page = (params[:per_page] || 20).to_i
  43. total = users.count
  44. users = users.skip((page - 1) * per_page).limit(per_page)
  45. render json: {
  46. data: users.map { |u| user_response(u) },
  47. meta: {
  48. total: total,
  49. page: page,
  50. per_page: per_page,
  51. total_pages: (total.to_f / per_page).ceil
  52. }
  53. }
  54. end
  55. # GET /api/v1/admin/users/:id
  56. def show
  57. render json: { data: user_response(@user, full: true) }
  58. end
  59. # POST /api/v1/admin/users
  60. def create
  61. @user = ::Identity::User.new(user_params)
  62. @user.organization = current_organization
  63. @user.password = params[:user][:password] || SecureRandom.hex(8)
  64. @user.must_change_password = true
  65. if @user.save
  66. # Assign roles
  67. assign_roles_to_user(@user, params[:user][:role_names])
  68. render json: {
  69. data: user_response(@user),
  70. message: "Usuario creado correctamente"
  71. }, status: :created
  72. else
  73. render json: { error: @user.errors.full_messages.join(", ") }, status: :unprocessable_entity
  74. end
  75. end
  76. # PATCH /api/v1/admin/users/:id
  77. def update
  78. if @user.update(user_params)
  79. # Update roles if provided
  80. if params[:user][:role_names].present?
  81. assign_roles_to_user(@user, params[:user][:role_names])
  82. end
  83. render json: {
  84. data: user_response(@user),
  85. message: "Usuario actualizado correctamente"
  86. }
  87. else
  88. render json: { error: @user.errors.full_messages.join(", ") }, status: :unprocessable_entity
  89. end
  90. end
  91. # DELETE /api/v1/admin/users/:id
  92. def destroy
  93. if @user == current_user
  94. return render json: { error: "No puedes eliminar tu propio usuario" }, status: :unprocessable_entity
  95. end
  96. @user.soft_delete!
  97. render json: { message: "Usuario eliminado correctamente" }
  98. end
  99. # POST /api/v1/admin/users/:id/toggle_active
  100. def toggle_active
  101. if @user == current_user
  102. return render json: { error: "No puedes desactivar tu propio usuario" }, status: :unprocessable_entity
  103. end
  104. if @user.active?
  105. @user.deactivate!
  106. message = "Usuario desactivado"
  107. else
  108. @user.activate!
  109. message = "Usuario activado"
  110. end
  111. render json: { data: user_response(@user), message: message }
  112. end
  113. # POST /api/v1/admin/users/:id/assign_roles
  114. def assign_roles
  115. role_names = params[:role_names] || []
  116. assign_roles_to_user(@user, role_names)
  117. render json: {
  118. data: user_response(@user),
  119. message: "Roles actualizados correctamente"
  120. }
  121. end
  122. # GET /api/v1/admin/users/roles
  123. def roles
  124. roles = ::Identity::Role.all.by_level.map do |role|
  125. {
  126. name: role.name,
  127. display_name: role.display_name,
  128. description: role.description,
  129. level: role.level_value,
  130. system_role: role.system_role
  131. }
  132. end
  133. render json: { data: roles }
  134. end
  135. # GET /api/v1/admin/users/stats
  136. def stats
  137. users = ::Identity::User.where(organization_id: current_organization.id)
  138. stats = {
  139. total: users.count,
  140. active: users.enabled.count,
  141. inactive: users.disabled.count,
  142. by_role: {},
  143. by_level: {}
  144. }
  145. # Count by role
  146. ::Identity::Role.all.each do |role|
  147. count = users.where(:role_ids.in => [role.id]).count
  148. stats[:by_role][role.name] = count if count > 0
  149. end
  150. # Count by level
  151. (1..5).each do |level|
  152. role_name = ::Identity::Role::LEVELS[level]
  153. role = ::Identity::Role.find_by(name: role_name)
  154. stats[:by_level][level] = users.where(:role_ids.in => [role&.id].compact).count if role
  155. end
  156. render json: { data: stats }
  157. end
  158. private
  159. def set_user
  160. @user = ::Identity::User.find(params[:id])
  161. end
  162. def user_params
  163. params.require(:user).permit(
  164. :email, :first_name, :last_name, :employee_id,
  165. :department, :title, :phone, :time_zone, :locale, :active
  166. )
  167. end
  168. def require_admin
  169. unless current_user.admin?
  170. render json: { error: "No autorizado. Se requiere rol de administrador." }, status: :forbidden
  171. end
  172. end
  173. def assign_roles_to_user(user, role_names)
  174. return if role_names.nil?
  175. # Clear existing roles
  176. user.roles = []
  177. # Assign new roles
  178. role_names.each do |role_name|
  179. role = ::Identity::Role.find_by(name: role_name)
  180. user.roles << role if role
  181. end
  182. user.save!
  183. end
  184. def user_response(user, full: false)
  185. # Get employee data if exists
  186. employee = ::Hr::Employee.find_by(user_id: user.id)
  187. # Use employee's department/title if user's is empty
  188. department = user.department.presence || employee&.department
  189. title = user.title.presence || employee&.job_title
  190. response = {
  191. id: user.id.to_s,
  192. email: user.email,
  193. first_name: user.first_name,
  194. last_name: user.last_name,
  195. full_name: user.full_name,
  196. department: department,
  197. title: title,
  198. active: user.active,
  199. roles: user.role_names,
  200. permission_level: user.permission_level,
  201. level_name: user.level_name,
  202. created_at: user.created_at&.iso8601,
  203. last_sign_in_at: user.last_sign_in_at&.iso8601,
  204. has_employee: employee.present?,
  205. employee_id: employee&.id&.to_s
  206. }
  207. if full
  208. response.merge!(
  209. employee_id: user.employee_id,
  210. phone: user.phone,
  211. time_zone: user.time_zone,
  212. locale: user.locale,
  213. sign_in_count: user.sign_in_count,
  214. must_change_password: user.must_change_password,
  215. organization_id: user.organization_id&.to_s
  216. )
  217. end
  218. response
  219. end
  220. end
  221. end
  222. end
  223. end

app/controllers/api/v1/admin/variable_mappings_controller.rb

0.0% lines covered

374 relevant lines. 0 lines covered and 374 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Admin
  5. class VariableMappingsController < BaseController
  6. before_action :ensure_admin_or_hr
  7. before_action :set_mapping, only: [:show, :update, :destroy, :toggle_active]
  8. # GET /api/v1/admin/variable_mappings
  9. def index
  10. @mappings = ::Templates::VariableMapping
  11. .for_organization(current_organization)
  12. .ordered
  13. # Filter by category
  14. @mappings = @mappings.by_category(params[:category]) if params[:category].present?
  15. # Filter by active status
  16. if params[:active].present?
  17. @mappings = params[:active] == "true" ? @mappings.active : @mappings.inactive
  18. end
  19. # Filter system vs custom
  20. if params[:type].present?
  21. @mappings = params[:type] == "system" ? @mappings.system_mappings : @mappings.custom_mappings
  22. end
  23. render json: {
  24. data: @mappings.map { |m| mapping_json(m) },
  25. meta: {
  26. total: @mappings.count,
  27. categories: ::Templates::VariableMapping::CATEGORIES,
  28. data_types: ::Templates::VariableMapping::DATA_TYPES
  29. }
  30. }
  31. end
  32. # GET /api/v1/admin/variable_mappings/grouped
  33. def grouped
  34. grouped = ::Templates::VariableMapping.grouped_for(current_organization)
  35. render json: {
  36. data: grouped.transform_values { |mappings| mappings.map { |m| mapping_json(m) } }
  37. }
  38. end
  39. # GET /api/v1/admin/variable_mappings/:id
  40. def show
  41. render json: { data: mapping_json(@mapping) }
  42. end
  43. # POST /api/v1/admin/variable_mappings
  44. def create
  45. @mapping = ::Templates::VariableMapping.new(mapping_params)
  46. @mapping.organization = current_organization
  47. @mapping.created_by = current_user
  48. @mapping.is_system = false
  49. if @mapping.save
  50. render json: {
  51. data: mapping_json(@mapping),
  52. message: "Mapeo creado exitosamente"
  53. }, status: :created
  54. else
  55. render json: {
  56. error: "Error al crear mapeo",
  57. errors: @mapping.errors.full_messages
  58. }, status: :unprocessable_content
  59. end
  60. end
  61. # PATCH /api/v1/admin/variable_mappings/:id
  62. def update
  63. if @mapping.update(mapping_params)
  64. render json: {
  65. data: mapping_json(@mapping),
  66. message: "Mapeo actualizado exitosamente"
  67. }
  68. else
  69. render json: {
  70. error: "Error al actualizar mapeo",
  71. errors: @mapping.errors.full_messages
  72. }, status: :unprocessable_content
  73. end
  74. end
  75. # DELETE /api/v1/admin/variable_mappings/:id
  76. def destroy
  77. # Only admins can delete system mappings
  78. if @mapping.system? && !current_user.admin?
  79. return render json: {
  80. error: "Solo administradores pueden eliminar mapeos del sistema"
  81. }, status: :forbidden
  82. end
  83. @mapping.destroy
  84. render json: { message: "Mapeo eliminado exitosamente" }
  85. end
  86. # POST /api/v1/admin/variable_mappings/:id/toggle_active
  87. def toggle_active
  88. @mapping.toggle_active!
  89. render json: {
  90. data: mapping_json(@mapping),
  91. message: @mapping.active? ? "Mapeo activado" : "Mapeo desactivado"
  92. }
  93. end
  94. # POST /api/v1/admin/variable_mappings/seed_system
  95. def seed_system
  96. ::Templates::VariableMapping.seed_system_mappings!
  97. render json: {
  98. message: "Mapeos del sistema creados exitosamente",
  99. count: ::Templates::VariableMapping.system_mappings.count
  100. }
  101. end
  102. # POST /api/v1/admin/variable_mappings/reorder
  103. def reorder
  104. return render json: { error: "Se requiere lista de IDs" }, status: :bad_request unless params[:ids].present?
  105. params[:ids].each_with_index do |uuid, index|
  106. mapping = ::Templates::VariableMapping.find_by(uuid: uuid)
  107. mapping&.update!(position: index)
  108. end
  109. render json: { message: "Orden actualizado" }
  110. end
  111. # GET /api/v1/admin/variable_mappings/pending_variables
  112. # Params: module_type (hr, legal, admin) - filter templates by module
  113. def pending_variables
  114. templates = ::Templates::Template.for_organization(current_organization)
  115. templates = templates.by_module(params[:module_type]) if params[:module_type].present?
  116. available_mappings = ::Templates::VariableMapping.available_for(current_organization)
  117. pending_data = []
  118. templates.each do |template|
  119. next if template.variables.blank?
  120. template.variables.each do |variable|
  121. # Check if this variable has a mapping assigned
  122. has_mapping = template.variable_mappings[variable].present?
  123. next if has_mapping
  124. # Find suggestions based on similarity
  125. suggestions = find_suggestions(variable, available_mappings)
  126. pending_data << {
  127. template_id: template.uuid,
  128. template_name: template.name,
  129. template_category: template.category_label,
  130. template_status: template.status,
  131. variable: variable,
  132. suggestions: suggestions
  133. }
  134. end
  135. end
  136. # Group by variable name
  137. grouped = pending_data.group_by { |p| p[:variable] }
  138. render json: {
  139. data: {
  140. pending_variables: pending_data,
  141. grouped_by_variable: grouped.transform_values do |items|
  142. {
  143. count: items.size,
  144. templates: items.map { |i| { id: i[:template_id], name: i[:template_name] } },
  145. suggestions: items.first[:suggestions]
  146. }
  147. end,
  148. summary: {
  149. total_pending: pending_data.size,
  150. unique_variables: grouped.keys.size,
  151. templates_with_pending: pending_data.map { |p| p[:template_id] }.uniq.size
  152. }
  153. }
  154. }
  155. end
  156. # POST /api/v1/admin/variable_mappings/auto_assign
  157. def auto_assign
  158. variable_name = params[:variable]
  159. mapping_key = params[:mapping_key]
  160. template_ids = params[:template_ids] || []
  161. return render json: { error: "Se requiere variable y mapping_key" }, status: :bad_request if variable_name.blank? || mapping_key.blank?
  162. # Normalize the variable name to match stored format
  163. normalized_variable = ::Templates::VariableNormalizer.normalize(variable_name)
  164. updated_count = 0
  165. templates = if template_ids.present?
  166. ::Templates::Template.where(:uuid.in => template_ids)
  167. else
  168. ::Templates::Template.for_organization(current_organization)
  169. end
  170. templates.each do |template|
  171. next unless template.variables&.include?(normalized_variable)
  172. next if template.variable_mappings[normalized_variable].present?
  173. template.variable_mappings[normalized_variable] = mapping_key
  174. template.save!
  175. updated_count += 1
  176. end
  177. render json: {
  178. message: "Mapeo asignado exitosamente",
  179. updated_templates: updated_count
  180. }
  181. end
  182. # POST /api/v1/admin/variable_mappings/merge
  183. # Merge variables: keep primary, convert others to aliases (same key, different names)
  184. def merge
  185. primary_id = params[:primary_id]
  186. alias_ids = params[:alias_ids] || []
  187. return render json: { error: "Se requiere primary_id" }, status: :bad_request if primary_id.blank?
  188. return render json: { error: "Se requiere al menos un alias_id" }, status: :bad_request if alias_ids.empty?
  189. primary = ::Templates::VariableMapping.find_by(uuid: primary_id)
  190. return render json: { error: "Variable principal no encontrada" }, status: :not_found unless primary
  191. merged_names = [primary.name]
  192. merged_count = 0
  193. alias_ids.each do |alias_id|
  194. alias_mapping = ::Templates::VariableMapping.find_by(uuid: alias_id)
  195. next unless alias_mapping
  196. next if alias_mapping.uuid == primary.uuid
  197. next if alias_mapping.key == primary.key # Already linked
  198. old_key = alias_mapping.key
  199. # Update the alias mapping to use the primary's key
  200. alias_mapping.update!(
  201. key: primary.key,
  202. description: "Alias de #{primary.name}"
  203. )
  204. # Update any templates using the old key for this variable name
  205. update_templates_with_mapping(alias_mapping.name, primary.key)
  206. merged_names << alias_mapping.name
  207. merged_count += 1
  208. end
  209. render json: {
  210. message: "Variables fusionadas exitosamente",
  211. primary: mapping_json(primary),
  212. merged_count: merged_count,
  213. aliases: merged_names
  214. }
  215. end
  216. # GET /api/v1/admin/variable_mappings/aliases
  217. # Get all variables that have aliases
  218. def aliases
  219. mappings = ::Templates::VariableMapping.available_for(current_organization)
  220. .select { |m| m.aliases.present? && m.aliases.any? }
  221. render json: {
  222. data: mappings.map { |m| mapping_json(m) },
  223. meta: {
  224. total_with_aliases: mappings.size,
  225. total_aliases: mappings.sum { |m| m.aliases.size }
  226. }
  227. }
  228. end
  229. # POST /api/v1/admin/variable_mappings/:id/add_alias
  230. # Add an alias to an existing variable
  231. def add_alias
  232. mapping = ::Templates::VariableMapping.find_by(uuid: params[:id])
  233. return render json: { error: "Variable no encontrada" }, status: :not_found unless mapping
  234. alias_name = params[:alias_name]
  235. return render json: { error: "Se requiere alias_name" }, status: :bad_request if alias_name.blank?
  236. # Normalize the alias name
  237. normalized_name = ::Templates::VariableNormalizer.normalize(alias_name)
  238. # Check if alias already exists in this mapping
  239. if mapping.name == normalized_name || mapping.aliases.include?(normalized_name)
  240. return render json: { error: "Este alias ya existe en esta variable" }, status: :unprocessable_content
  241. end
  242. # Check if alias is already a name or alias in another mapping
  243. existing = ::Templates::VariableMapping.find_by_name_or_alias(normalized_name)
  244. if existing && existing.uuid != mapping.uuid
  245. return render json: { error: "Este nombre ya existe en otra variable: #{existing.name}" }, status: :unprocessable_content
  246. end
  247. mapping.add_alias(alias_name)
  248. render json: {
  249. data: mapping_json(mapping.reload),
  250. message: "Alias agregado exitosamente"
  251. }
  252. end
  253. # DELETE /api/v1/admin/variable_mappings/:id/remove_alias
  254. # Remove an alias from a variable
  255. def remove_alias
  256. mapping = ::Templates::VariableMapping.find_by(uuid: params[:id])
  257. return render json: { error: "Variable no encontrada" }, status: :not_found unless mapping
  258. alias_name = params[:alias_name]
  259. return render json: { error: "Se requiere alias_name" }, status: :bad_request if alias_name.blank?
  260. normalized_name = ::Templates::VariableNormalizer.normalize(alias_name)
  261. unless mapping.aliases.include?(normalized_name)
  262. return render json: { error: "Este alias no existe en esta variable" }, status: :unprocessable_content
  263. end
  264. mapping.remove_alias(alias_name)
  265. render json: {
  266. data: mapping_json(mapping.reload),
  267. message: "Alias eliminado exitosamente"
  268. }
  269. end
  270. # POST /api/v1/admin/variable_mappings/create_and_assign
  271. def create_and_assign
  272. variable_name = params[:variable]
  273. mapping_data = params[:mapping]&.permit(:name, :key, :category, :description, :data_type) || {}
  274. template_ids = params[:template_ids] || []
  275. return render json: { error: "Se requiere variable" }, status: :bad_request if variable_name.blank?
  276. # Normalize the variable name
  277. normalized_variable = ::Templates::VariableNormalizer.normalize(variable_name)
  278. # Generate key from normalized variable name if not provided
  279. generated_key = "custom.#{::Templates::VariableNormalizer.to_key(variable_name)}"
  280. # Create the new mapping (name will be normalized by model callback)
  281. @mapping = ::Templates::VariableMapping.new(
  282. name: mapping_data[:name].presence || normalized_variable,
  283. key: mapping_data[:key].presence || generated_key,
  284. category: mapping_data[:category].presence || "custom",
  285. description: mapping_data[:description].presence || "Variable personalizada: #{normalized_variable}",
  286. data_type: mapping_data[:data_type].presence || "string",
  287. organization: current_organization,
  288. created_by: current_user,
  289. is_system: false
  290. )
  291. unless @mapping.save
  292. return render json: {
  293. error: "Error al crear mapeo",
  294. errors: @mapping.errors.full_messages
  295. }, status: :unprocessable_content
  296. end
  297. # Assign to templates using normalized variable name
  298. updated_count = 0
  299. templates = if template_ids.present?
  300. ::Templates::Template.where(:uuid.in => template_ids)
  301. else
  302. ::Templates::Template.for_organization(current_organization)
  303. end
  304. templates.each do |template|
  305. next unless template.variables&.include?(normalized_variable)
  306. next if template.variable_mappings[normalized_variable].present?
  307. template.variable_mappings[normalized_variable] = @mapping.key
  308. template.save!
  309. updated_count += 1
  310. end
  311. render json: {
  312. data: mapping_json(@mapping),
  313. message: "Mapeo creado y asignado exitosamente",
  314. updated_templates: updated_count
  315. }, status: :created
  316. end
  317. private
  318. def find_suggestions(variable, available_mappings)
  319. normalized_var = normalize_string(variable)
  320. suggestions = available_mappings.map do |mapping|
  321. # Check primary name
  322. name_score = calculate_similarity(normalized_var, normalize_string(mapping.name))
  323. # Check aliases and take the best match
  324. alias_scores = (mapping.aliases || []).map { |a| calculate_similarity(normalized_var, normalize_string(a)) }
  325. best_alias_score = alias_scores.max || 0
  326. # Use the best score between name and aliases
  327. best_score = [name_score, best_alias_score].max
  328. { mapping: mapping_json(mapping), score: best_score }
  329. end
  330. # Return top 3 suggestions with score > 0.3
  331. suggestions
  332. .select { |s| s[:score] > 0.3 }
  333. .sort_by { |s| -s[:score] }
  334. .first(3)
  335. .map { |s| s[:mapping].merge(match_score: (s[:score] * 100).round) }
  336. end
  337. def normalize_string(str)
  338. str.to_s.downcase
  339. .gsub(/[áàäâ]/, "a")
  340. .gsub(/[éèëê]/, "e")
  341. .gsub(/[íìïî]/, "i")
  342. .gsub(/[óòöô]/, "o")
  343. .gsub(/[úùüû]/, "u")
  344. .gsub(/[ñ]/, "n")
  345. .gsub(/[^a-z0-9]/, "")
  346. end
  347. def update_templates_with_mapping(variable_name, new_key)
  348. normalized_name = ::Templates::VariableNormalizer.normalize(variable_name)
  349. ::Templates::Template.for_organization(current_organization).each do |template|
  350. next unless template.variables&.include?(normalized_name)
  351. template.variable_mappings[normalized_name] = new_key
  352. template.save!
  353. end
  354. end
  355. def calculate_similarity(str1, str2)
  356. return 1.0 if str1 == str2
  357. return 0.0 if str1.empty? || str2.empty?
  358. # Check for substring match
  359. if str1.include?(str2) || str2.include?(str1)
  360. return 0.8
  361. end
  362. # Simple word overlap similarity
  363. words1 = str1.scan(/[a-z]+/)
  364. words2 = str2.scan(/[a-z]+/)
  365. return 0.0 if words1.empty? || words2.empty?
  366. common = (words1 & words2).size
  367. total = [words1.size, words2.size].max
  368. common.to_f / total
  369. end
  370. def ensure_admin_or_hr
  371. return if current_user.admin? || current_user.has_role?("hr")
  372. render json: {
  373. error: "Acceso denegado. Se requieren privilegios de administrador o HR."
  374. }, status: :forbidden
  375. end
  376. def set_mapping
  377. @mapping = ::Templates::VariableMapping.find_by(uuid: params[:id])
  378. return if @mapping
  379. render json: { error: "Mapeo no encontrado" }, status: :not_found
  380. end
  381. def mapping_params
  382. params.require(:mapping).permit(
  383. :name,
  384. :key,
  385. :category,
  386. :description,
  387. :data_type,
  388. :format_pattern,
  389. :source_model,
  390. :source_field,
  391. :active,
  392. :position,
  393. aliases: []
  394. )
  395. end
  396. def mapping_json(mapping)
  397. {
  398. id: mapping.uuid,
  399. name: mapping.name,
  400. key: mapping.key,
  401. category: mapping.category,
  402. category_label: mapping.category_label,
  403. description: mapping.description,
  404. data_type: mapping.data_type,
  405. format_pattern: mapping.format_pattern,
  406. source_model: mapping.source_model,
  407. source_field: mapping.source_field,
  408. is_system: mapping.is_system,
  409. active: mapping.active,
  410. position: mapping.position,
  411. aliases: mapping.aliases || [],
  412. all_names: mapping.all_names,
  413. created_at: mapping.created_at.iso8601
  414. }
  415. end
  416. end
  417. end
  418. end
  419. end

app/controllers/api/v1/auth/passwords_controller.rb

0.0% lines covered

87 relevant lines. 0 lines covered and 87 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Auth
  5. class PasswordsController < ApplicationController
  6. before_action :authenticate_user!
  7. # PATCH /api/v1/auth/password
  8. def update
  9. unless current_user.valid_password?(password_params[:current_password])
  10. return render json: { error: "La contraseña actual es incorrecta" }, status: :unprocessable_content
  11. end
  12. if password_params[:password] != password_params[:password_confirmation]
  13. return render json: { error: "Las contraseñas no coinciden" }, status: :unprocessable_content
  14. end
  15. if current_user.update(password: password_params[:password])
  16. # Clear must_change_password flag if set
  17. current_user.password_changed! if current_user.must_change_password?
  18. render json: { message: "Contraseña actualizada exitosamente" }, status: :ok
  19. else
  20. render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_content
  21. end
  22. end
  23. # POST /api/v1/auth/password/force_change
  24. # For users who must change password on first login
  25. # Also updates corporate email
  26. def force_change
  27. unless current_user.must_change_password?
  28. return render json: { error: "No se requiere cambio de contraseña" }, status: :unprocessable_content
  29. end
  30. corporate_email = force_change_params[:corporate_email]
  31. if corporate_email.blank?
  32. return render json: { error: "El correo corporativo es requerido" }, status: :unprocessable_content
  33. end
  34. unless corporate_email.match?(/\A[^@\s]+@[^@\s]+\z/)
  35. return render json: { error: "El correo electronico no es valido" }, status: :unprocessable_content
  36. end
  37. # Check if email is already taken by another user
  38. existing_user = Identity::User.where(email: corporate_email).where(:id.ne => current_user.id).first
  39. if existing_user
  40. return render json: { error: "El correo electronico ya esta en uso" }, status: :unprocessable_content
  41. end
  42. if force_change_params[:new_password] != force_change_params[:new_password_confirmation]
  43. return render json: { error: "Las contraseñas no coinciden" }, status: :unprocessable_content
  44. end
  45. if force_change_params[:new_password].length < 8
  46. return render json: { error: "La contraseña debe tener al menos 8 caracteres" }, status: :unprocessable_content
  47. end
  48. # Update user email and password
  49. if current_user.update(email: corporate_email, password: force_change_params[:new_password])
  50. current_user.password_changed!
  51. # Update employee work_email if exists
  52. employee = ::Hr::Employee.for_user(current_user)
  53. employee&.update(work_email: corporate_email)
  54. # Generate new token after password change
  55. token = Warden::JWTAuth::UserEncoder.new.call(current_user, :identity_user, nil).first
  56. render json: {
  57. message: "Cuenta actualizada exitosamente. Tu nuevo correo es: #{corporate_email}",
  58. token: token,
  59. data: user_response(current_user)
  60. }, status: :ok
  61. else
  62. render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_content
  63. end
  64. end
  65. private
  66. def authenticate_user!
  67. return if current_user
  68. render json: {
  69. error: "Unauthorized",
  70. message: "You need to sign in or sign up before continuing."
  71. }, status: :unauthorized
  72. end
  73. def current_user
  74. @current_user ||= warden.authenticate(scope: :identity_user)
  75. end
  76. def password_params
  77. params.permit(:current_password, :password, :password_confirmation)
  78. end
  79. def force_change_params
  80. params.permit(:corporate_email, :new_password, :new_password_confirmation)
  81. end
  82. def user_response(user)
  83. employee = ::Hr::Employee.for_user(user)
  84. {
  85. id: user.id.to_s,
  86. email: user.email,
  87. first_name: user.first_name,
  88. last_name: user.last_name,
  89. full_name: user.full_name,
  90. roles: user.role_names,
  91. must_change_password: user.must_change_password || false
  92. }
  93. end
  94. end
  95. end
  96. end
  97. end

app/controllers/api/v1/auth/profiles_controller.rb

0.0% lines covered

59 relevant lines. 0 lines covered and 59 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Auth
  5. class ProfilesController < ApplicationController
  6. before_action :authenticate_user!
  7. # PATCH /api/v1/auth/profile
  8. def update
  9. if current_user.update(profile_params)
  10. render json: { data: user_response(current_user) }, status: :ok
  11. else
  12. render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_content
  13. end
  14. end
  15. private
  16. def authenticate_user!
  17. return if current_user
  18. render json: {
  19. error: "Unauthorized",
  20. message: "You need to sign in or sign up before continuing."
  21. }, status: :unauthorized
  22. end
  23. def current_user
  24. @current_user ||= warden.authenticate(scope: :identity_user)
  25. end
  26. def profile_params
  27. params.require(:user).permit(:first_name, :last_name, :time_zone, :locale)
  28. end
  29. def user_response(user)
  30. employee = ::Hr::Employee.for_user(user)
  31. vacation_info = employee ? ::Hr::VacationCalculator.new(employee).summary : nil
  32. {
  33. id: user.id.to_s,
  34. email: user.email,
  35. first_name: user.first_name,
  36. last_name: user.last_name,
  37. full_name: user.full_name,
  38. department: user.department,
  39. title: user.title,
  40. roles: user.role_names,
  41. permissions: user.permission_names,
  42. permission_level: user.permission_level,
  43. organization_id: user.organization_id&.to_s,
  44. time_zone: user.time_zone,
  45. locale: user.locale,
  46. is_supervisor: employee&.supervisor? || false,
  47. is_hr: employee&.hr_staff? || employee&.hr_manager? || false,
  48. employee: employee ? {
  49. id: employee.uuid,
  50. employee_number: employee.employee_number,
  51. job_title: employee.job_title,
  52. department: employee.department,
  53. hire_date: employee.hire_date&.iso8601
  54. } : nil,
  55. vacation: vacation_info
  56. }
  57. end
  58. end
  59. end
  60. end
  61. end

app/controllers/api/v1/auth/sessions_controller.rb

0.0% lines covered

105 relevant lines. 0 lines covered and 105 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Auth
  5. class SessionsController < ApplicationController
  6. before_action :authenticate_user!, only: [:show, :destroy]
  7. def show
  8. render json: {
  9. data: user_response(current_user)
  10. }, status: :ok
  11. end
  12. def create
  13. user = Identity::User.where(email: login_params[:email]&.downcase).first
  14. if user.nil?
  15. render_error("Invalid email or password", status: :unauthorized)
  16. elsif !user.valid_password?(login_params[:password])
  17. handle_failed_login(user)
  18. elsif !user.active?
  19. render_error("Account is deactivated", status: :unauthorized)
  20. else
  21. handle_successful_login(user)
  22. end
  23. end
  24. def destroy
  25. if current_user
  26. # JWT revocation is handled by Warden middleware via JwtDenylist.revoke_jwt
  27. render json: { message: "Logged out successfully" }, status: :ok
  28. else
  29. render_error("Not logged in", status: :unauthorized)
  30. end
  31. end
  32. private
  33. def login_params
  34. # Support both { user: { email, password } } and { email, password } formats
  35. if params[:user].present?
  36. params.require(:user).permit(:email, :password)
  37. else
  38. params.permit(:email, :password)
  39. end
  40. end
  41. def handle_successful_login(user)
  42. user.update_tracked_fields!(request)
  43. token = generate_jwt_token(user)
  44. render json: {
  45. data: user_response(user),
  46. token: token
  47. }, status: :ok
  48. end
  49. def handle_failed_login(user)
  50. user&.increment_failed_attempts if user.respond_to?(:increment_failed_attempts)
  51. render_error("Invalid email or password", status: :unauthorized)
  52. end
  53. def generate_jwt_token(user)
  54. Warden::JWTAuth::UserEncoder.new.call(user, :identity_user, nil).first
  55. end
  56. def revoke_jwt_token
  57. token = request.headers["Authorization"]&.split&.last
  58. return unless token
  59. begin
  60. payload = Warden::JWTAuth::TokenDecoder.new.call(token)
  61. Identity::JwtDenylist.revoke_jwt(payload, current_user)
  62. rescue JWT::DecodeError
  63. # Token already invalid
  64. end
  65. end
  66. def user_response(user)
  67. employee = ::Hr::Employee.for_user(user)
  68. vacation_info = employee ? ::Hr::VacationCalculator.new(employee).summary : nil
  69. {
  70. id: user.id.to_s,
  71. email: user.email,
  72. first_name: user.first_name,
  73. last_name: user.last_name,
  74. full_name: user.full_name,
  75. department: user.department,
  76. title: user.title,
  77. roles: user.role_names,
  78. permissions: user.permission_names,
  79. permission_level: user.permission_level,
  80. organization_id: user.organization_id&.to_s,
  81. time_zone: user.time_zone,
  82. locale: user.locale,
  83. is_supervisor: employee&.supervisor? || false,
  84. is_hr: employee&.hr_staff? || employee&.hr_manager? || false,
  85. must_change_password: user.must_change_password || false,
  86. employee: employee ? {
  87. id: employee.uuid,
  88. employee_number: employee.employee_number,
  89. job_title: employee.job_title,
  90. department: employee.department,
  91. hire_date: employee.hire_date&.iso8601
  92. } : nil,
  93. vacation: vacation_info
  94. }
  95. end
  96. def current_user
  97. @current_user ||= warden.authenticate(scope: :identity_user)
  98. end
  99. def authenticate_user!
  100. return if current_user
  101. render json: {
  102. error: "Unauthorized",
  103. message: "You need to sign in or sign up before continuing."
  104. }, status: :unauthorized
  105. end
  106. end
  107. end
  108. end
  109. end

app/controllers/api/v1/auth/signatures_controller.rb

0.0% lines covered

135 relevant lines. 0 lines covered and 135 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Auth
  5. class SignaturesController < BaseController
  6. before_action :set_signature, only: [:show, :update, :destroy, :set_default, :toggle_active]
  7. # GET /api/v1/auth/signatures
  8. def index
  9. @signatures = current_user.signatures.order(created_at: :desc)
  10. render json: {
  11. data: @signatures.map { |s| signature_json(s) },
  12. meta: {
  13. total: @signatures.count,
  14. has_default: current_user.signatures.default_signature.exists?
  15. }
  16. }
  17. end
  18. # GET /api/v1/auth/signatures/:id
  19. def show
  20. render json: { data: signature_json(@signature, include_image: true) }
  21. end
  22. # POST /api/v1/auth/signatures
  23. def create
  24. @signature = current_user.signatures.build(signature_params)
  25. # Set as default if it's the first signature
  26. @signature.is_default = true if current_user.signatures.empty?
  27. if @signature.save
  28. render json: {
  29. data: signature_json(@signature, include_image: true),
  30. message: "Firma creada exitosamente"
  31. }, status: :created
  32. else
  33. render json: {
  34. error: "Error al crear la firma",
  35. errors: @signature.errors.full_messages
  36. }, status: :unprocessable_content
  37. end
  38. end
  39. # PATCH /api/v1/auth/signatures/:id
  40. def update
  41. if @signature.update(signature_params)
  42. render json: {
  43. data: signature_json(@signature, include_image: true),
  44. message: "Firma actualizada exitosamente"
  45. }
  46. else
  47. render json: {
  48. error: "Error al actualizar la firma",
  49. errors: @signature.errors.full_messages
  50. }, status: :unprocessable_content
  51. end
  52. end
  53. # DELETE /api/v1/auth/signatures/:id
  54. def destroy
  55. if @signature.in_use?
  56. return render json: {
  57. error: "Esta firma está siendo utilizada en documentos",
  58. in_use: true,
  59. documents_count: @signature.documents_using_count,
  60. message: "No se puede eliminar. Use la opción de desactivar en su lugar."
  61. }, status: :unprocessable_content
  62. end
  63. @signature.destroy
  64. render json: {
  65. message: "Firma eliminada exitosamente"
  66. }
  67. end
  68. # POST /api/v1/auth/signatures/:id/toggle_active
  69. def toggle_active
  70. if @signature.active?
  71. @signature.disable!
  72. message = "Firma desactivada exitosamente"
  73. else
  74. @signature.enable!
  75. message = "Firma activada exitosamente"
  76. end
  77. render json: {
  78. data: signature_json(@signature, include_image: true),
  79. message: message
  80. }
  81. end
  82. # POST /api/v1/auth/signatures/:id/set_default
  83. def set_default
  84. @signature.set_as_default!
  85. render json: {
  86. data: signature_json(@signature),
  87. message: "Firma establecida como predeterminada"
  88. }
  89. end
  90. # GET /api/v1/auth/signatures/fonts
  91. def fonts
  92. render json: {
  93. data: Identity::UserSignature::SIGNATURE_FONTS.map do |font|
  94. {
  95. name: font,
  96. css_family: font.gsub(" ", "+"),
  97. google_font_url: "https://fonts.googleapis.com/css2?family=#{font.gsub(' ', '+')}&display=swap"
  98. }
  99. end
  100. }
  101. end
  102. private
  103. def set_signature
  104. @signature = current_user.signatures.find_by(uuid: params[:id])
  105. return if @signature
  106. render json: { error: "Firma no encontrada" }, status: :not_found
  107. end
  108. def signature_params
  109. params.require(:signature).permit(
  110. :name,
  111. :signature_type,
  112. :image_data,
  113. :styled_text,
  114. :font_family,
  115. :font_color,
  116. :font_size,
  117. :is_default
  118. )
  119. end
  120. def signature_json(signature, include_image: false)
  121. json = {
  122. id: signature.uuid,
  123. name: signature.name,
  124. signature_type: signature.signature_type,
  125. is_default: signature.is_default,
  126. active: signature.active?,
  127. in_use: signature.in_use?,
  128. documents_count: signature.documents_using_count,
  129. created_at: signature.created_at.iso8601
  130. }
  131. if signature.styled?
  132. json[:styled_text] = signature.styled_text
  133. json[:font_family] = signature.font_family
  134. json[:font_color] = signature.font_color
  135. json[:font_size] = signature.font_size
  136. end
  137. if include_image
  138. json[:image_data] = signature.to_image_data
  139. end
  140. json
  141. end
  142. end
  143. end
  144. end
  145. end

app/controllers/api/v1/base_controller.rb

0.0% lines covered

49 relevant lines. 0 lines covered and 49 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. class BaseController < ApplicationController
  5. include Pundit::Authorization
  6. before_action :authenticate_user!
  7. rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
  8. protected
  9. def current_user
  10. @current_user ||= warden.authenticate(scope: :identity_user)
  11. end
  12. def current_organization
  13. @current_organization ||= current_user&.organization
  14. end
  15. def authenticate_user!
  16. return if current_user
  17. render json: {
  18. error: "Unauthorized",
  19. message: "You need to sign in or sign up before continuing."
  20. }, status: :unauthorized
  21. end
  22. def pundit_user
  23. current_user
  24. end
  25. # Simple pagination without external gem
  26. def paginate(scope)
  27. page = (params[:page] || 1).to_i
  28. per_page = [(params[:per_page] || 20).to_i, 100].min
  29. @pagination_page = page
  30. @pagination_per_page = per_page
  31. @pagination_total = scope.count
  32. scope.skip((page - 1) * per_page).limit(per_page)
  33. end
  34. def pagination_meta(_scope = nil)
  35. {
  36. current_page: @pagination_page,
  37. per_page: @pagination_per_page,
  38. total_count: @pagination_total,
  39. total_pages: (@pagination_total.to_f / @pagination_per_page).ceil
  40. }
  41. end
  42. def render_error(message, status: :bad_request, errors: [])
  43. render json: { error: message, errors: Array(errors) }, status: status
  44. end
  45. private
  46. def user_not_authorized
  47. render json: { error: "You are not authorized to perform this action" }, status: :forbidden
  48. end
  49. end
  50. end
  51. end

app/controllers/api/v1/content/documents_controller.rb

0.0% lines covered

134 relevant lines. 0 lines covered and 134 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Content
  5. class DocumentsController < BaseController
  6. before_action :set_document, only: [:show, :update, :destroy, :lock, :unlock]
  7. rescue_from ::Content::Document::ConcurrencyError, with: :handle_concurrency_error
  8. rescue_from ::Content::Document::DocumentLockedError, with: :handle_locked_error
  9. def index
  10. authorize ::Content::Document
  11. documents = policy_scope(::Content::Document)
  12. documents = apply_filters(documents)
  13. render json: {
  14. data: documents.map { |d| document_response(d) }
  15. }, status: :ok
  16. end
  17. def show
  18. authorize @document
  19. render json: {
  20. data: document_response(@document, include_version: true)
  21. }, status: :ok
  22. end
  23. def create
  24. authorize ::Content::Document
  25. document = ::Content::Document.new(document_params)
  26. document.created_by = current_user
  27. document.organization = current_user.organization
  28. if document.save
  29. # Create initial version if content provided
  30. if version_params[:content].present? || version_params[:file_name].present?
  31. document.create_version!(version_params)
  32. end
  33. render json: { data: document_response(document, include_version: true) }, status: :created
  34. else
  35. render_errors(document.errors.full_messages)
  36. end
  37. end
  38. def update
  39. authorize @document
  40. @document.update_with_lock!(document_params.to_h)
  41. render json: { data: document_response(@document) }, status: :ok
  42. end
  43. def destroy
  44. authorize @document
  45. @document.soft_delete!
  46. render json: { message: "Document deleted successfully" }, status: :ok
  47. end
  48. def lock
  49. authorize @document, :update?
  50. if @document.lock!(current_user)
  51. render json: { data: document_response(@document), message: "Document locked" }, status: :ok
  52. else
  53. render_error("Unable to lock document", status: :conflict)
  54. end
  55. end
  56. def unlock
  57. authorize @document, :update?
  58. if @document.unlock!(current_user)
  59. render json: { data: document_response(@document), message: "Document unlocked" }, status: :ok
  60. else
  61. render_error("Unable to unlock document", status: :conflict)
  62. end
  63. end
  64. private
  65. def set_document
  66. @document = ::Content::Document.find(params[:id])
  67. end
  68. def document_params
  69. params.require(:document).permit(
  70. :title, :description, :status, :document_type, :folder_id,
  71. tags: [], metadata: {}
  72. )
  73. end
  74. def version_params
  75. params.fetch(:version, {}).permit(
  76. :file_name, :content, :content_type, :change_summary,
  77. metadata: {}
  78. )
  79. end
  80. def apply_filters(documents)
  81. documents = documents.by_folder(params[:folder_id]) if params[:folder_id].present?
  82. documents = documents.where(status: params[:status]) if params[:status].present?
  83. documents = documents.by_type(params[:document_type]) if params[:document_type].present?
  84. documents = documents.tagged_with(params[:tag]) if params[:tag].present?
  85. documents
  86. end
  87. # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
  88. def document_response(document, include_version: false)
  89. response = {
  90. id: document.id.to_s,
  91. uuid: document.uuid,
  92. title: document.title,
  93. description: document.description,
  94. status: document.status,
  95. document_type: document.document_type,
  96. tags: document.tags,
  97. folder_id: document.folder_id&.to_s,
  98. organization_id: document.organization_id&.to_s,
  99. current_version_number: document.current_version_number,
  100. version_count: document.version_count,
  101. locked: document.locked?,
  102. locked_by_id: document.locked_by_id&.to_s,
  103. locked_at: document.locked_at&.iso8601,
  104. created_by_id: document.created_by_id&.to_s,
  105. metadata: document.metadata,
  106. created_at: document.created_at.iso8601,
  107. updated_at: document.updated_at.iso8601
  108. }
  109. if include_version && document.current_version
  110. response[:current_version] = version_response(document.current_version)
  111. end
  112. response
  113. end
  114. # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
  115. def version_response(version)
  116. {
  117. id: version.id.to_s,
  118. uuid: version.uuid,
  119. version_number: version.version_number,
  120. file_name: version.file_name,
  121. file_size: version.file_size,
  122. content_type: version.content_type,
  123. checksum: version.checksum,
  124. change_summary: version.change_summary,
  125. created_by_id: version.created_by_id&.to_s,
  126. created_at: version.created_at.iso8601
  127. }
  128. end
  129. def handle_concurrency_error(exception)
  130. render_error(exception.message, status: :conflict)
  131. end
  132. def handle_locked_error(exception)
  133. render_error(exception.message, status: :locked)
  134. end
  135. end
  136. end
  137. end
  138. end

app/controllers/api/v1/content/folders_controller.rb

0.0% lines covered

84 relevant lines. 0 lines covered and 84 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Content
  5. class FoldersController < BaseController
  6. before_action :set_folder, only: [:show, :update, :destroy]
  7. def index
  8. authorize ::Content::Folder
  9. folders = policy_scope(::Content::Folder)
  10. if params[:parent_id]
  11. folders = folders.by_parent(params[:parent_id])
  12. elsif params[:root]
  13. folders = folders.root_folders
  14. end
  15. folders = folders.alphabetical
  16. render json: {
  17. data: folders.map { |f| folder_response(f) }
  18. }, status: :ok
  19. end
  20. def show
  21. authorize @folder
  22. render json: {
  23. data: folder_response(@folder, include_children: true)
  24. }, status: :ok
  25. end
  26. def create
  27. authorize ::Content::Folder
  28. folder = ::Content::Folder.new(folder_params)
  29. folder.created_by = current_user
  30. folder.organization = current_user.organization
  31. if folder.save
  32. render json: { data: folder_response(folder) }, status: :created
  33. else
  34. render_errors(folder.errors.full_messages)
  35. end
  36. end
  37. def update
  38. authorize @folder
  39. if @folder.update(folder_params)
  40. render json: { data: folder_response(@folder) }, status: :ok
  41. else
  42. render_errors(@folder.errors.full_messages)
  43. end
  44. end
  45. def destroy
  46. authorize @folder
  47. if @folder.children.any? || @folder.documents.any?
  48. render_error("Cannot delete folder with contents", status: :unprocessable_entity)
  49. else
  50. @folder.soft_delete!
  51. render json: { message: "Folder deleted successfully" }, status: :ok
  52. end
  53. end
  54. private
  55. def set_folder
  56. @folder = ::Content::Folder.find(params[:id])
  57. end
  58. def folder_params
  59. params.require(:folder).permit(:name, :description, :parent_id, metadata: {})
  60. end
  61. # rubocop:disable Metrics/AbcSize
  62. def folder_response(folder, include_children: false)
  63. response = {
  64. id: folder.id.to_s,
  65. uuid: folder.uuid,
  66. name: folder.name,
  67. description: folder.description,
  68. path: folder.path,
  69. depth: folder.depth,
  70. parent_id: folder.parent_id&.to_s,
  71. organization_id: folder.organization_id&.to_s,
  72. document_count: folder.document_count,
  73. metadata: folder.metadata,
  74. created_at: folder.created_at.iso8601,
  75. updated_at: folder.updated_at.iso8601
  76. }
  77. if include_children
  78. response[:children] = folder.children.alphabetical.map { |c| folder_response(c) }
  79. response[:ancestors] = folder.ancestors.map { |a| { id: a.id.to_s, name: a.name, path: a.path } }
  80. end
  81. response
  82. end
  83. # rubocop:enable Metrics/AbcSize
  84. end
  85. end
  86. end
  87. end

app/controllers/api/v1/content/versions_controller.rb

0.0% lines covered

87 relevant lines. 0 lines covered and 87 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Content
  5. class VersionsController < BaseController
  6. before_action :set_document
  7. before_action :set_version, only: [:show]
  8. rescue_from ::Content::Document::ConcurrencyError, with: :handle_concurrency_error
  9. rescue_from ::Content::Document::DocumentLockedError, with: :handle_locked_error
  10. def index
  11. authorize @document, :show?
  12. versions = @document.version_history
  13. render json: {
  14. data: versions.map { |v| version_response(v) }
  15. }, status: :ok
  16. end
  17. def show
  18. authorize @document, :show?
  19. render json: {
  20. data: version_response(@version, include_content: params[:include_content] == "true")
  21. }, status: :ok
  22. end
  23. def create
  24. authorize @document, :update?
  25. version = @document.create_version!(version_params)
  26. render json: {
  27. data: version_response(version),
  28. message: "Version #{version.version_number} created successfully"
  29. }, status: :created
  30. end
  31. def current
  32. authorize @document, :show?
  33. version = @document.current_version
  34. if version
  35. render json: { data: version_response(version, include_content: true) }, status: :ok
  36. else
  37. render_error("No versions found", status: :not_found)
  38. end
  39. end
  40. private
  41. def set_document
  42. @document = ::Content::Document.find(params[:document_id])
  43. end
  44. def set_version
  45. @version = if params[:id] == "current"
  46. @document.current_version
  47. else
  48. @document.versions.find(params[:id])
  49. end
  50. raise Mongoid::Errors::DocumentNotFound.new(::Content::DocumentVersion, params[:id]) unless @version
  51. end
  52. def version_params
  53. permitted = params.require(:version).permit(
  54. :file_name, :content, :content_type, :file_size, :change_summary,
  55. metadata: {}
  56. )
  57. permitted[:created_by] = current_user
  58. permitted
  59. end
  60. def version_response(version, include_content: false)
  61. response = {
  62. id: version.id.to_s,
  63. uuid: version.uuid,
  64. document_id: version.document_id.to_s,
  65. version_number: version.version_number,
  66. file_name: version.file_name,
  67. file_size: version.file_size,
  68. content_type: version.content_type,
  69. checksum: version.checksum,
  70. change_summary: version.change_summary,
  71. is_latest: version.latest?,
  72. created_by_id: version.created_by_id&.to_s,
  73. metadata: version.metadata,
  74. created_at: version.created_at.iso8601
  75. }
  76. response[:content] = version.content if include_content
  77. response
  78. end
  79. def handle_concurrency_error(exception)
  80. render_error(exception.message, status: :conflict)
  81. end
  82. def handle_locked_error(exception)
  83. render_error(exception.message, status: :locked)
  84. end
  85. end
  86. end
  87. end
  88. end

app/controllers/api/v1/documents_controller.rb

0.0% lines covered

191 relevant lines. 0 lines covered and 191 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. class DocumentsController < BaseController
  5. before_action :set_document, only: [:show, :download, :preview, :destroy, :sign]
  6. # GET /api/v1/documents
  7. def index
  8. page = (params[:page] || 1).to_i
  9. per_page = (params[:per_page] || 20).to_i
  10. skip_count = (page - 1) * per_page
  11. base_scope = policy_scope(::Templates::GeneratedDocument).order(created_at: :desc)
  12. # Apply filters
  13. base_scope = apply_document_filters(base_scope)
  14. total_count = base_scope.count
  15. documents = base_scope.skip(skip_count).limit(per_page).to_a
  16. total_pages = (total_count.to_f / per_page).ceil
  17. render json: {
  18. data: documents.map { |doc| document_json(doc) },
  19. meta: {
  20. current_page: page,
  21. total_pages: total_pages,
  22. total_count: total_count,
  23. per_page: per_page
  24. }
  25. }
  26. end
  27. # GET /api/v1/documents/:id
  28. def show
  29. authorize @document
  30. render json: {
  31. data: document_json(@document, detailed: true)
  32. }
  33. end
  34. # GET /api/v1/documents/:id/download
  35. def download
  36. authorize @document
  37. file_content = @document.file_content
  38. if file_content
  39. send_data file_content,
  40. filename: @document.file_name || "#{@document.name}.pdf",
  41. type: "application/pdf",
  42. disposition: "attachment"
  43. else
  44. render json: { error: "El archivo no está disponible" }, status: :not_found
  45. end
  46. end
  47. # GET /api/v1/documents/:id/preview
  48. def preview
  49. authorize @document
  50. file_content = @document.file_content
  51. if file_content
  52. send_data file_content,
  53. filename: @document.file_name || "#{@document.name}.pdf",
  54. type: "application/pdf",
  55. disposition: "inline"
  56. else
  57. render json: { error: "El archivo no está disponible" }, status: :not_found
  58. end
  59. end
  60. # DELETE /api/v1/documents/:id
  61. def destroy
  62. authorize @document
  63. # Delete associated files from GridFS
  64. if @document.draft_file_id
  65. Mongoid::GridFs.delete(@document.draft_file_id) rescue nil
  66. end
  67. if @document.final_file_id
  68. Mongoid::GridFs.delete(@document.final_file_id) rescue nil
  69. end
  70. @document.destroy!
  71. render json: { message: "Documento eliminado exitosamente" }
  72. end
  73. # GET /api/v1/documents/pending_signatures
  74. # Returns documents pending signature by the current user
  75. def pending_signatures
  76. documents = ::Templates::GeneratedDocument
  77. .where(organization_id: current_organization.id)
  78. .pending_signature_by(current_user)
  79. .order(created_at: :desc)
  80. render json: {
  81. data: documents.map { |doc| document_json(doc, detailed: true) },
  82. meta: {
  83. total: documents.count
  84. }
  85. }
  86. end
  87. # POST /api/v1/documents/:id/sign
  88. # Signs the document with the current user's digital signature
  89. def sign
  90. authorize @document
  91. # Check if user can sign this document
  92. unless @document.can_be_signed_by?(current_user)
  93. return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
  94. end
  95. # Get user's default signature
  96. signature = current_user.signatures.active.default_signature.first || current_user.signatures.active.first
  97. unless signature
  98. return render json: { error: "No tienes una firma digital configurada. Configura tu firma en tu perfil." }, status: :unprocessable_entity
  99. end
  100. @document.sign!(user: current_user, signature: signature)
  101. render json: {
  102. data: document_json(@document, detailed: true),
  103. message: "Documento firmado exitosamente",
  104. all_signed: @document.all_required_signed?
  105. }
  106. rescue ::Templates::GeneratedDocument::SignatureError => e
  107. render json: { error: e.message }, status: :unprocessable_entity
  108. end
  109. private
  110. def set_document
  111. return render json: { error: "ID de documento requerido" }, status: :bad_request if params[:id].blank?
  112. @document = ::Templates::GeneratedDocument.where(uuid: params[:id]).first
  113. return if @document
  114. render json: { error: "Documento no encontrado" }, status: :not_found
  115. end
  116. def apply_document_filters(scope)
  117. # Filter by category (can be comma-separated for multiple categories)
  118. if params[:category].present?
  119. categories = params[:category].split(",").map(&:strip)
  120. template_ids = ::Templates::Template.where(:category.in => categories).pluck(:id)
  121. scope = scope.where(:template_id.in => template_ids)
  122. end
  123. # Filter by status
  124. if params[:status].present?
  125. scope = scope.where(status: params[:status])
  126. end
  127. # Filter by search query
  128. if params[:q].present?
  129. query = /#{Regexp.escape(params[:q])}/i
  130. scope = scope.or({ name: query })
  131. end
  132. # Filter by employee_id (accepts UUID or MongoDB ObjectId)
  133. if params[:employee_id].present?
  134. employee = ::Hr::Employee.find_by(uuid: params[:employee_id]) ||
  135. ::Hr::Employee.where(id: params[:employee_id]).first
  136. scope = scope.where(employee_id: employee&.id) if employee
  137. end
  138. # Filter by module (hr or legal)
  139. if params[:module].present?
  140. case params[:module]
  141. when "hr"
  142. # HR documents: includes employee contracts (with employee_id) and other HR categories
  143. hr_categories = %w[vacation certification employee_contract employee contract]
  144. template_ids = ::Templates::Template.where(:category.in => hr_categories).pluck(:id)
  145. # Filter by template categories, but for 'contract' only include those with employee_id
  146. contract_template_ids = ::Templates::Template.where(category: "contract").pluck(:id)
  147. other_template_ids = template_ids - contract_template_ids
  148. scope = scope.where(
  149. "$or" => [
  150. { :template_id.in => other_template_ids },
  151. { :template_id.in => contract_template_ids, :employee_id.ne => nil }
  152. ]
  153. )
  154. when "legal"
  155. # Legal documents: contracts without employee_id (third party contracts)
  156. legal_categories = %w[contract legal]
  157. template_ids = ::Templates::Template.where(:category.in => legal_categories).pluck(:id)
  158. scope = scope.where(:template_id.in => template_ids, :employee_id => nil)
  159. end
  160. end
  161. scope
  162. end
  163. def document_json(document, detailed: false)
  164. employee = document.employee
  165. template = document.template
  166. json = {
  167. id: document.uuid,
  168. name: document.name,
  169. file_name: document.file_name,
  170. status: document.status,
  171. template_name: template&.name,
  172. template_category: template&.category,
  173. employee_name: employee&.full_name,
  174. employee_number: employee&.employee_number,
  175. created_at: document.created_at.iso8601,
  176. requested_by: document.requested_by&.full_name,
  177. can_sign: document.can_be_signed_by?(current_user),
  178. has_pending_signature: document.signatures.any? { |s| s["user_id"] == current_user.id.to_s && s["status"] == "pending" },
  179. user_has_digital_signature: current_user.signatures.active.exists?
  180. }
  181. if detailed
  182. json.merge!(
  183. variable_values: document.variable_values,
  184. sequential_signing: document.sequential_signing?,
  185. signatures: document.signatures.map do |sig|
  186. order_status = document.signature_with_order_status(sig)
  187. {
  188. signatory_label: sig["signatory_label"],
  189. signatory_type_code: sig["signatory_type_code"],
  190. user_name: sig["user_name"],
  191. user_id: sig["user_id"],
  192. status: sig["status"],
  193. required: sig["required"],
  194. signed_at: sig["signed_at"],
  195. signed_by_name: sig["signed_by_name"],
  196. can_sign_now: order_status[:can_sign_now],
  197. waiting_for: order_status[:waiting_for]
  198. }
  199. end,
  200. pending_signatures_count: document.pending_signatures_count,
  201. completed_signatures_count: document.completed_signatures_count,
  202. total_required_signatures: document.total_required_signatures,
  203. all_signed: document.all_required_signed?,
  204. next_signatory: document.next_signatory_to_sign&.dig("signatory_label"),
  205. completed_at: document.completed_at&.iso8601,
  206. can_download: document.draft_file_id.present?
  207. )
  208. end
  209. json
  210. end
  211. end
  212. end
  213. end

app/controllers/api/v1/folders_controller.rb

0.0% lines covered

148 relevant lines. 0 lines covered and 148 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. class FoldersController < BaseController
  5. before_action :set_folder, only: [:show, :update, :destroy, :add_document, :remove_document]
  6. # GET /api/v1/folders
  7. def index
  8. folders = ::Documents::Folder
  9. .for_organization(current_organization)
  10. .ordered
  11. # Filter by parent
  12. if params[:parent_id].present?
  13. if params[:parent_id] == "root"
  14. folders = folders.root_folders
  15. else
  16. parent = ::Documents::Folder.find_by(uuid: params[:parent_id])
  17. folders = folders.where(parent_id: parent&.id)
  18. end
  19. else
  20. folders = folders.root_folders
  21. end
  22. render json: {
  23. data: folders.map { |f| folder_json(f) },
  24. meta: { total: folders.count }
  25. }
  26. end
  27. # GET /api/v1/folders/:id
  28. def show
  29. render json: {
  30. data: folder_json(@folder, detailed: true)
  31. }
  32. end
  33. # POST /api/v1/folders
  34. def create
  35. folder = ::Documents::Folder.new(folder_params)
  36. folder.organization = current_organization
  37. folder.created_by = current_user
  38. # Handle parent folder
  39. if params[:folder][:parent_id].present?
  40. parent = ::Documents::Folder.find_by(uuid: params[:folder][:parent_id])
  41. folder.parent = parent
  42. end
  43. if folder.save
  44. render json: {
  45. data: folder_json(folder),
  46. message: "Carpeta creada exitosamente"
  47. }, status: :created
  48. else
  49. render json: { error: folder.errors.full_messages.join(", ") }, status: :unprocessable_entity
  50. end
  51. end
  52. # PATCH /api/v1/folders/:id
  53. def update
  54. if @folder.is_system
  55. return render json: { error: "No se puede modificar una carpeta del sistema" }, status: :forbidden
  56. end
  57. if @folder.update(folder_params)
  58. render json: {
  59. data: folder_json(@folder),
  60. message: "Carpeta actualizada exitosamente"
  61. }
  62. else
  63. render json: { error: @folder.errors.full_messages.join(", ") }, status: :unprocessable_entity
  64. end
  65. end
  66. # DELETE /api/v1/folders/:id
  67. def destroy
  68. if @folder.is_system
  69. return render json: { error: "No se puede eliminar una carpeta del sistema" }, status: :forbidden
  70. end
  71. if @folder.subfolders.any?
  72. return render json: { error: "No se puede eliminar una carpeta que contiene subcarpetas" }, status: :conflict
  73. end
  74. @folder.destroy!
  75. render json: { message: "Carpeta eliminada exitosamente" }
  76. end
  77. # POST /api/v1/folders/:id/documents
  78. def add_document
  79. document = ::Templates::GeneratedDocument.find_by(uuid: params[:document_id])
  80. unless document
  81. return render json: { error: "Documento no encontrado" }, status: :not_found
  82. end
  83. folder_doc = @folder.folder_documents.new(
  84. document: document,
  85. added_by: current_user
  86. )
  87. if folder_doc.save
  88. render json: {
  89. data: folder_json(@folder),
  90. message: "Documento agregado a la carpeta"
  91. }
  92. else
  93. render json: { error: folder_doc.errors.full_messages.join(", ") }, status: :unprocessable_entity
  94. end
  95. end
  96. # DELETE /api/v1/folders/:id/documents/:document_id
  97. def remove_document
  98. document = ::Templates::GeneratedDocument.find_by(uuid: params[:document_id])
  99. unless document
  100. return render json: { error: "Documento no encontrado" }, status: :not_found
  101. end
  102. folder_doc = @folder.folder_documents.find_by(document_id: document.id)
  103. unless folder_doc
  104. return render json: { error: "El documento no está en esta carpeta" }, status: :not_found
  105. end
  106. folder_doc.destroy!
  107. render json: {
  108. data: folder_json(@folder),
  109. message: "Documento removido de la carpeta"
  110. }
  111. end
  112. private
  113. def set_folder
  114. @folder = ::Documents::Folder.find_by(
  115. uuid: params[:id],
  116. organization_id: current_organization.id
  117. )
  118. unless @folder
  119. render json: { error: "Carpeta no encontrada" }, status: :not_found
  120. end
  121. end
  122. def folder_params
  123. params.require(:folder).permit(:name, :description, :color, :icon)
  124. end
  125. def folder_json(folder, detailed: false)
  126. json = {
  127. id: folder.uuid,
  128. name: folder.name,
  129. description: folder.description,
  130. color: folder.color,
  131. icon: folder.icon,
  132. is_system: folder.is_system,
  133. documents_count: folder.documents_count,
  134. subfolders_count: folder.subfolders.count,
  135. parent_id: folder.parent&.uuid,
  136. created_at: folder.created_at.iso8601
  137. }
  138. if detailed
  139. json[:full_path] = folder.full_path
  140. json[:ancestors] = folder.ancestors.map { |a| { id: a.uuid, name: a.name } }
  141. json[:subfolders] = folder.subfolders.ordered.map { |s| folder_json(s) }
  142. json[:documents] = folder.folder_documents.includes(:document).map do |fd|
  143. doc = fd.document
  144. next unless doc
  145. {
  146. id: doc.uuid,
  147. name: doc.name,
  148. status: doc.status,
  149. created_at: doc.created_at.iso8601,
  150. added_at: fd.created_at.iso8601
  151. }
  152. end.compact
  153. end
  154. json
  155. end
  156. end
  157. end
  158. end

app/controllers/api/v1/health_controller.rb

0.0% lines covered

23 relevant lines. 0 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. class HealthController < ApplicationController
  5. skip_before_action :set_request_context, only: [:show]
  6. def show
  7. result = HealthCheckService.call
  8. if result.success?
  9. render json: {
  10. status: result.result[:status],
  11. checks: result.result[:checks],
  12. timestamp: result.result[:timestamp],
  13. version: result.result[:version]
  14. }, status: result.result[:status] == "healthy" ? :ok : :service_unavailable
  15. else
  16. render json: {
  17. status: "error",
  18. message: result.errors.first
  19. }, status: :internal_server_error
  20. end
  21. end
  22. end
  23. end
  24. end

app/controllers/api/v1/hr/approvals_controller.rb

0.0% lines covered

212 relevant lines. 0 lines covered and 212 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Hr
  5. # Approvals management for supervisors and HR staff
  6. # rubocop:disable Metrics/ClassLength
  7. class ApprovalsController < BaseController
  8. before_action :ensure_approver_access
  9. before_action :set_approvable, only: [:show, :approve, :reject]
  10. # GET /api/v1/hr/approvals
  11. # Params: status=pending (default) or status=history
  12. def index
  13. if params[:status] == "history"
  14. @approvals = fetch_history_approvals
  15. render json: {
  16. data: {
  17. vacation_requests: @approvals[:vacations].map { |v| vacation_json(v) },
  18. certification_requests: @approvals[:certifications].map { |c| certification_json(c) }
  19. },
  20. meta: {
  21. total: @approvals[:vacations].count + @approvals[:certifications].count
  22. }
  23. }
  24. else
  25. @approvals = fetch_pending_approvals
  26. render json: {
  27. data: {
  28. vacation_requests: @approvals[:vacations].map { |v| vacation_json(v) },
  29. certification_requests: @approvals[:certifications].map { |c| certification_json(c) }
  30. },
  31. meta: {
  32. total_pending: @approvals[:vacations].count + @approvals[:certifications].count
  33. }
  34. }
  35. end
  36. end
  37. # GET /api/v1/hr/approvals/:id
  38. def show
  39. render json: { data: approvable_json(@approvable, detailed: true) }
  40. end
  41. # POST /api/v1/hr/approvals/:id/approve
  42. def approve
  43. case @approvable
  44. when ::Hr::VacationRequest
  45. @approvable.approve!(actor: current_employee, reason: params[:reason])
  46. when ::Hr::EmploymentCertificationRequest
  47. # Certifications need to go through processing first
  48. @approvable.start_processing!(actor: current_employee) if @approvable.pending?
  49. @approvable.complete!(actor: current_employee, document_uuid: params[:document_uuid] || SecureRandom.uuid)
  50. end
  51. render json: {
  52. data: approvable_json(@approvable),
  53. message: "Request approved successfully"
  54. }
  55. rescue StandardError => e
  56. handle_approval_error(e)
  57. end
  58. # POST /api/v1/hr/approvals/:id/reject
  59. def reject
  60. return render_missing_reason if params[:reason].blank?
  61. @approvable.reject!(actor: current_employee, reason: params[:reason])
  62. render json: {
  63. data: approvable_json(@approvable),
  64. message: "Request rejected"
  65. }
  66. rescue StandardError => e
  67. handle_approval_error(e)
  68. end
  69. private
  70. def ensure_approver_access
  71. return if current_employee.hr_staff? || current_employee.hr_manager? || current_employee.supervisor?
  72. render json: { error: "Access denied. Approver privileges required." }, status: :forbidden
  73. end
  74. def set_approvable
  75. @approvable = find_approvable(params[:id])
  76. return if @approvable
  77. render json: { error: "Request not found" }, status: :not_found
  78. end
  79. def find_approvable(uuid)
  80. ::Hr::VacationRequest.where(uuid: uuid).first ||
  81. ::Hr::EmploymentCertificationRequest.where(uuid: uuid).first
  82. end
  83. def fetch_pending_approvals
  84. {
  85. vacations: pending_vacations_scope,
  86. certifications: pending_certifications_scope
  87. }
  88. end
  89. def fetch_history_approvals
  90. {
  91. vacations: history_vacations_scope,
  92. certifications: history_certifications_scope
  93. }
  94. end
  95. def pending_vacations_scope
  96. base_scope(::Hr::VacationRequest).pending.order(submitted_at: :asc)
  97. end
  98. def pending_certifications_scope
  99. base_scope(::Hr::EmploymentCertificationRequest).pending.order(submitted_at: :asc)
  100. end
  101. def history_vacations_scope
  102. base_scope(::Hr::VacationRequest)
  103. .where(:status.in => %w[approved rejected cancelled])
  104. .order(decided_at: :desc)
  105. .limit(50)
  106. end
  107. def history_certifications_scope
  108. base_scope(::Hr::EmploymentCertificationRequest)
  109. .where(:status.in => %w[completed rejected cancelled])
  110. .order(completed_at: :desc)
  111. .limit(50)
  112. end
  113. def base_scope(klass)
  114. if current_employee.hr_staff? || current_employee.hr_manager?
  115. klass.where(organization_id: current_organization.id)
  116. else
  117. klass.where(:employee_id.in => current_employee.subordinates.pluck(:id))
  118. end
  119. end
  120. def approvable_json(record, detailed: false)
  121. case record
  122. when ::Hr::VacationRequest
  123. vacation_json(record, detailed: detailed)
  124. when ::Hr::EmploymentCertificationRequest
  125. certification_json(record, detailed: detailed)
  126. end
  127. end
  128. def vacation_json(vacation, detailed: false)
  129. json = {
  130. id: vacation.uuid,
  131. type: "vacation_request",
  132. request_number: vacation.request_number,
  133. vacation_type: vacation.vacation_type,
  134. start_date: vacation.start_date&.iso8601,
  135. end_date: vacation.end_date&.iso8601,
  136. days_requested: vacation.days_requested,
  137. status: vacation.status,
  138. submitted_at: vacation.submitted_at&.iso8601,
  139. employee: employee_summary(vacation.employee)
  140. }
  141. if detailed
  142. json.merge!(
  143. reason: vacation.reason,
  144. notes: vacation.notes,
  145. history: vacation.history
  146. )
  147. end
  148. json
  149. end
  150. def certification_json(certification, detailed: false)
  151. json = {
  152. id: certification.uuid,
  153. type: "certification_request",
  154. request_number: certification.request_number,
  155. certification_type: certification.certification_type,
  156. purpose: certification.purpose,
  157. status: certification.status,
  158. estimated_days: certification.estimated_days,
  159. submitted_at: certification.submitted_at&.iso8601,
  160. employee: employee_summary(certification.employee)
  161. }
  162. if detailed
  163. json.merge!(
  164. language: certification.language,
  165. include_salary: certification.include_salary,
  166. include_position: certification.include_position,
  167. additional_info: certification.additional_info
  168. )
  169. end
  170. json
  171. end
  172. def employee_summary(employee)
  173. return nil unless employee
  174. {
  175. id: employee.uuid,
  176. name: employee.full_name,
  177. department: employee.department,
  178. job_title: employee.job_title,
  179. available_vacation_days: employee.available_vacation_days
  180. }
  181. end
  182. def current_employee
  183. @current_employee ||= ::Hr::Employee.for_user(current_user) ||
  184. ::Hr::Employee.create!(
  185. user: current_user,
  186. organization: current_organization,
  187. job_title: current_user.title,
  188. department: current_user.department,
  189. hire_date: Date.current,
  190. vacation_balance_days: 15.0
  191. )
  192. end
  193. def render_missing_reason
  194. render json: { error: "Rejection reason is required" }, status: :unprocessable_content
  195. end
  196. def handle_approval_error(error)
  197. Rails.logger.error "Approval error: #{error.class} - #{error.message}"
  198. Rails.logger.error error.backtrace.first(10).join("\n")
  199. status = error_status_for(error)
  200. if status
  201. render json: { error: error.message }, status: status
  202. else
  203. render json: { error: "Error processing approval: #{error.message}" }, status: :internal_server_error
  204. end
  205. end
  206. def error_status_for(error)
  207. case error
  208. when ::Hr::VacationRequest::InvalidStateError,
  209. ::Hr::EmploymentCertificationRequest::InvalidStateError,
  210. ::Hr::VacationRequest::ValidationError,
  211. ::Hr::EmploymentCertificationRequest::ValidationError
  212. :unprocessable_content
  213. when ::Hr::VacationRequest::AuthorizationError,
  214. ::Hr::EmploymentCertificationRequest::AuthorizationError
  215. :forbidden
  216. end
  217. end
  218. end
  219. # rubocop:enable Metrics/ClassLength
  220. end
  221. end
  222. end

app/controllers/api/v1/hr/certifications_controller.rb

0.0% lines covered

354 relevant lines. 0 lines covered and 354 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Hr
  5. # Employment certification requests management
  6. class CertificationsController < BaseController
  7. before_action :set_certification, only: [:show, :update, :destroy, :cancel, :generate_document, :download_document, :sign_document]
  8. # GET /api/v1/hr/certifications
  9. def index
  10. @certifications = policy_scope(::Hr::EmploymentCertificationRequest)
  11. .order(created_at: :desc)
  12. @certifications = apply_filters(@certifications)
  13. @certifications = paginate(@certifications)
  14. render json: {
  15. data: @certifications.map { |c| certification_json(c) },
  16. meta: pagination_meta(@certifications)
  17. }
  18. end
  19. # GET /api/v1/hr/certifications/:id
  20. def show
  21. authorize @certification
  22. render json: { data: certification_json(@certification, detailed: true) }
  23. end
  24. # GET /api/v1/hr/certifications/available_types
  25. # Returns only certification types that have active templates
  26. def available_types
  27. available = fetch_available_certification_types
  28. render json: { data: available }
  29. end
  30. # POST /api/v1/hr/certifications
  31. def create
  32. @certification = ::Hr::EmploymentCertificationRequest.new(certification_params)
  33. @certification.employee = current_employee
  34. @certification.organization = current_organization
  35. authorize @certification
  36. if @certification.save
  37. render json: { data: certification_json(@certification) }, status: :created
  38. else
  39. render json: { errors: @certification.errors.full_messages }, status: :unprocessable_content
  40. end
  41. end
  42. # PATCH /api/v1/hr/certifications/:id
  43. def update
  44. authorize @certification
  45. unless @certification.pending?
  46. return render json: { error: "Can only update pending requests" }, status: :unprocessable_content
  47. end
  48. if @certification.update(certification_params)
  49. render json: { data: certification_json(@certification) }
  50. else
  51. render json: { errors: @certification.errors.full_messages }, status: :unprocessable_content
  52. end
  53. end
  54. # POST /api/v1/hr/certifications/:id/cancel
  55. def cancel
  56. authorize @certification, :cancel?
  57. @certification.cancel!(actor: current_employee)
  58. render json: {
  59. data: certification_json(@certification),
  60. message: "Certification request cancelled"
  61. }
  62. rescue ::Hr::EmploymentCertificationRequest::InvalidStateError => e
  63. render json: { error: e.message }, status: :unprocessable_content
  64. rescue ::Hr::EmploymentCertificationRequest::AuthorizationError => e
  65. render json: { error: e.message }, status: :forbidden
  66. end
  67. # DELETE /api/v1/hr/certifications/:id
  68. def destroy
  69. authorize @certification, :destroy?
  70. @certification.destroy!
  71. render json: { message: "Certificación eliminada exitosamente" }
  72. end
  73. # POST /api/v1/hr/certifications/:id/generate_document
  74. def generate_document
  75. authorize @certification, :generate_document?
  76. # Find appropriate template
  77. template = find_template_for_certification
  78. unless template
  79. return render json: {
  80. error: "No hay template activo para este tipo de certificación"
  81. }, status: :unprocessable_content
  82. end
  83. # Build context for variable resolution
  84. context = {
  85. employee: @certification.employee,
  86. organization: current_organization,
  87. request: @certification,
  88. user: current_user
  89. }
  90. # Generate document using robust service for better formatting
  91. generator = ::Templates::RobustDocumentGeneratorService.new(template, context)
  92. generated_doc = generator.generate!
  93. # Link document to certification
  94. @certification.update!(
  95. document_uuid: generated_doc.uuid,
  96. status: "processing"
  97. )
  98. render json: {
  99. data: {
  100. certification: certification_json(@certification, detailed: true),
  101. document: generated_document_json(generated_doc)
  102. },
  103. message: "Documento generado exitosamente"
  104. }
  105. rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
  106. render json: {
  107. error: e.message,
  108. error_type: "missing_variables",
  109. missing_data: e.missing_data,
  110. action_required: build_action_required(e.missing_data)
  111. }, status: :unprocessable_content
  112. rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
  113. render json: { error: e.message }, status: :unprocessable_content
  114. end
  115. # POST /api/v1/hr/certifications/:id/sign_document
  116. def sign_document
  117. authorize @certification, :sign_document?
  118. unless @certification.document_uuid
  119. return render json: { error: "No hay documento para firmar" }, status: :not_found
  120. end
  121. generated_doc = ::Templates::GeneratedDocument.where(uuid: @certification.document_uuid).first
  122. unless generated_doc
  123. return render json: { error: "Documento no encontrado" }, status: :not_found
  124. end
  125. # Verificar que el usuario puede firmar este documento
  126. unless generated_doc.can_be_signed_by?(current_user)
  127. # Verificar si es HR y hay firma pendiente de HR
  128. # Check both signatory_role and signatory_type_code for HR signatures
  129. pending_hr = generated_doc.signatures.find do |s|
  130. s["status"] == "pending" && (
  131. s["signatory_role"] == "hr" ||
  132. s["signatory_type_code"] == "hr" ||
  133. s["signatory_label"]&.downcase&.include?("recursos humanos")
  134. )
  135. end
  136. if pending_hr && hr_or_admin?
  137. # Asignar este usuario HR como firmante
  138. pending_hr["user_id"] = current_user.id.to_s
  139. pending_hr["user_name"] = current_user.full_name
  140. generated_doc.save!
  141. else
  142. return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
  143. end
  144. end
  145. # Obtener la firma digital del usuario
  146. signature = ::Identity::UserSignature.where(user_id: current_user.id, is_default: true).first
  147. signature ||= ::Identity::UserSignature.where(user_id: current_user.id).first
  148. unless signature
  149. return render json: {
  150. error: "No tienes firma digital configurada",
  151. action_required: {
  152. type: "configure_signature",
  153. label: "Configurar mi firma digital",
  154. url: "/profile"
  155. }
  156. }, status: :unprocessable_content
  157. end
  158. # Aplicar la firma
  159. generated_doc.sign!(user: current_user, signature: signature)
  160. render json: {
  161. message: "Documento firmado exitosamente",
  162. document: {
  163. uuid: generated_doc.uuid,
  164. status: generated_doc.status,
  165. pending_signatures: generated_doc.pending_signatories.map { |s| s["signatory_label"] },
  166. completed_signatures: generated_doc.signed_signatories.map { |s| s["signatory_label"] },
  167. all_signed: generated_doc.all_required_signed?
  168. }
  169. }
  170. rescue ::Templates::GeneratedDocument::SignatureError => e
  171. render json: { error: e.message }, status: :unprocessable_content
  172. end
  173. # GET /api/v1/hr/certifications/:id/download_document
  174. def download_document
  175. authorize @certification, :show?
  176. unless @certification.document_uuid
  177. return render json: { error: "No hay documento generado" }, status: :not_found
  178. end
  179. generated_doc = ::Templates::GeneratedDocument.where(uuid: @certification.document_uuid).first
  180. unless generated_doc
  181. return render json: { error: "Documento no encontrado" }, status: :not_found
  182. end
  183. # Empleados solo pueden descargar documentos con todas las firmas
  184. unless can_download_document?(generated_doc)
  185. pending = generated_doc.pending_signatories.map { |s| s["signatory_label"] }.join(", ")
  186. return render json: {
  187. error: "Documento pendiente de firmas",
  188. message: "El documento requiere firmas de: #{pending}",
  189. pending_signatures: generated_doc.pending_signatories,
  190. completed_signatures: generated_doc.signed_signatories
  191. }, status: :forbidden
  192. end
  193. file_content = generated_doc.file_content
  194. unless file_content
  195. return render json: { error: "Error al leer el archivo" }, status: :internal_server_error
  196. end
  197. send_data file_content,
  198. filename: generated_doc.file_name || "certificacion.pdf",
  199. type: "application/pdf",
  200. disposition: "inline"
  201. end
  202. private
  203. def set_certification
  204. @certification = ::Hr::EmploymentCertificationRequest.find_by!(uuid: params[:id])
  205. rescue Mongoid::Errors::DocumentNotFound
  206. render json: { error: "Certification request not found" }, status: :not_found
  207. end
  208. def certification_params
  209. params.require(:certification).permit(
  210. :certification_type,
  211. :purpose,
  212. :purpose_details,
  213. :language,
  214. :delivery_method,
  215. :addressee,
  216. :include_salary,
  217. :include_position,
  218. :include_department,
  219. :include_start_date,
  220. :special_instructions
  221. )
  222. end
  223. def apply_filters(scope)
  224. scope = scope.where(status: params[:status]) if params[:status].present?
  225. scope = scope.where(certification_type: params[:type]) if params[:type].present?
  226. scope
  227. end
  228. # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
  229. def certification_json(certification, detailed: false)
  230. # Get document info if exists
  231. doc_info = nil
  232. if certification.document_uuid
  233. generated_doc = ::Templates::GeneratedDocument.where(uuid: certification.document_uuid).first
  234. if generated_doc
  235. doc_info = {
  236. status: generated_doc.status,
  237. can_download: can_download_document?(generated_doc),
  238. pending_signatures: generated_doc.pending_signatories.map { |s| s["signatory_label"] },
  239. completed_signatures: generated_doc.signed_signatories.map { |s| s["signatory_label"] },
  240. all_signed: generated_doc.all_required_signed?
  241. }
  242. end
  243. end
  244. json = {
  245. id: certification.uuid,
  246. request_number: certification.request_number,
  247. certification_type: certification.certification_type,
  248. purpose: certification.purpose,
  249. status: certification.status,
  250. estimated_days: certification.estimated_days,
  251. submitted_at: certification.submitted_at&.iso8601,
  252. created_at: certification.created_at.iso8601,
  253. document_uuid: certification.document_uuid,
  254. document_info: doc_info
  255. }
  256. if detailed
  257. json.merge!(
  258. language: certification.language,
  259. delivery_method: certification.delivery_method,
  260. include_salary: certification.include_salary,
  261. include_position: certification.include_position,
  262. include_department: certification.include_department,
  263. include_start_date: certification.include_start_date,
  264. completed_at: certification.completed_at&.iso8601,
  265. rejection_reason: certification.rejection_reason,
  266. processed_by: certification.processed_by ? employee_summary(certification.processed_by) : nil
  267. )
  268. end
  269. json
  270. end
  271. # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
  272. def employee_summary(employee)
  273. {
  274. id: employee.uuid,
  275. name: employee.full_name,
  276. email: employee.user&.email
  277. }
  278. end
  279. def current_employee
  280. @current_employee ||= ::Hr::Employee.for_user(current_user) ||
  281. ::Hr::Employee.create!(
  282. user: current_user,
  283. organization: current_organization,
  284. job_title: current_user.title,
  285. department: current_user.department,
  286. hire_date: Date.current,
  287. vacation_balance_days: 15.0
  288. )
  289. end
  290. def find_template_for_certification
  291. # Find active template for certification category that matches the certification type
  292. ::Templates::Template
  293. .for_organization(current_organization)
  294. .active
  295. .where(category: "certification")
  296. .where(certification_type: @certification.certification_type)
  297. .first
  298. end
  299. # Returns certification types that have active templates
  300. def fetch_available_certification_types
  301. # All possible certification types
  302. all_types = ::Hr::EmploymentCertificationRequest::CERTIFICATION_TYPES
  303. # Find which types have active templates
  304. active_templates = ::Templates::Template
  305. .for_organization(current_organization)
  306. .active
  307. .where(category: "certification")
  308. .where(:certification_type.in => all_types)
  309. .pluck(:certification_type)
  310. .uniq
  311. # Map to type info
  312. type_labels = {
  313. "employment" => "Certificado de Empleo",
  314. "salary" => "Certificado de Salario",
  315. "position" => "Certificado de Cargo",
  316. "full" => "Certificado Completo",
  317. "custom" => "Certificado Personalizado"
  318. }
  319. type_descriptions = {
  320. "employment" => "Verificación básica de empleo",
  321. "salary" => "Incluye información salarial",
  322. "position" => "Detalla cargo y responsabilidades",
  323. "full" => "Información completa de empleo",
  324. "custom" => "Contenido personalizado según necesidad"
  325. }
  326. active_templates.map do |type|
  327. {
  328. value: type,
  329. label: type_labels[type] || type.humanize,
  330. description: type_descriptions[type] || ""
  331. }
  332. end
  333. end
  334. def generated_document_json(doc)
  335. {
  336. id: doc.uuid,
  337. name: doc.name,
  338. status: doc.status,
  339. file_name: doc.file_name,
  340. pending_signatures: doc.pending_signatures_count,
  341. total_signatures: doc.total_required_signatures,
  342. created_at: doc.created_at.iso8601
  343. }
  344. end
  345. def build_action_required(missing_data)
  346. actions = []
  347. if missing_data[:by_source]["employee"]&.any?
  348. actions << {
  349. type: "edit_employee",
  350. label: "Completar datos del empleado",
  351. employee_id: missing_data[:employee_id],
  352. employee_name: missing_data[:employee_name],
  353. fields: missing_data[:by_source]["employee"].map { |v| v[:field] }
  354. }
  355. end
  356. if missing_data[:by_source]["organization"]&.any?
  357. actions << {
  358. type: "edit_organization",
  359. label: "Completar datos de la organización",
  360. fields: missing_data[:by_source]["organization"].map { |v| v[:field] }
  361. }
  362. end
  363. if missing_data[:by_source][nil]&.any?
  364. actions << {
  365. type: "configure_mappings",
  366. label: "Configurar mapeo de variables",
  367. variables: missing_data[:by_source][nil].map { |v| v[:variable] }
  368. }
  369. end
  370. actions
  371. end
  372. # HR/Admin pueden descargar documentos sin firmas completas
  373. # Empleados solo pueden descargar documentos completamente firmados
  374. def can_download_document?(generated_doc)
  375. return true if hr_or_admin?
  376. return true if generated_doc.completed?
  377. return true if generated_doc.signatures.empty? # Sin requisito de firmas
  378. false
  379. end
  380. def hr_or_admin?
  381. current_user.has_role?(:admin) ||
  382. current_user.has_role?(:hr) ||
  383. current_user.has_role?(:hr_manager)
  384. end
  385. end
  386. end
  387. end
  388. end

app/controllers/api/v1/hr/dashboard_controller.rb

0.0% lines covered

26 relevant lines. 0 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Hr
  5. # HR Dashboard with statistics
  6. class DashboardController < BaseController
  7. before_action :ensure_hr_access
  8. # GET /api/v1/hr/dashboard
  9. def show
  10. stats = ::Hr::HrService.new(
  11. organization: current_organization,
  12. actor: current_employee
  13. ).statistics
  14. render json: { data: stats }
  15. rescue ::Hr::HrService::AuthorizationError => e
  16. render json: { error: e.message }, status: :forbidden
  17. end
  18. private
  19. def ensure_hr_access
  20. return if current_employee.hr_staff? || current_employee.hr_manager?
  21. render json: { error: "HR access required" }, status: :forbidden
  22. end
  23. def current_employee
  24. @current_employee ||= ::Hr::Employee.find_or_create_for_user!(current_user)
  25. end
  26. end
  27. end
  28. end
  29. end

app/controllers/api/v1/hr/employees_controller.rb

0.0% lines covered

380 relevant lines. 0 lines covered and 380 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Hr
  5. # Employee information (read-only for most users)
  6. class EmployeesController < BaseController
  7. before_action :set_employee, only: [:show, :update, :subordinates, :vacation_balance, :create_account, :generate_document]
  8. # GET /api/v1/hr/employees
  9. def index
  10. authorize ::Hr::Employee
  11. @employees = policy_scope(::Hr::Employee)
  12. .where(organization_id: current_organization.id)
  13. .active
  14. @employees = apply_filters(@employees)
  15. @employees = apply_sorting(@employees)
  16. @employees = paginate(@employees)
  17. render json: {
  18. data: @employees.map { |e| employee_json(e) },
  19. meta: pagination_meta(@employees)
  20. }
  21. end
  22. # GET /api/v1/hr/employees/:id
  23. def show
  24. authorize @employee
  25. render json: { data: employee_json(@employee, detailed: true) }
  26. end
  27. # PATCH /api/v1/hr/employees/:id
  28. def update
  29. authorize @employee
  30. if @employee.update(employee_params)
  31. render json: { data: employee_json(@employee, detailed: true) }
  32. else
  33. render json: { error: @employee.errors.full_messages.join(", ") }, status: :unprocessable_content
  34. end
  35. end
  36. # GET /api/v1/hr/employees/:id/subordinates
  37. def subordinates
  38. authorize @employee, :show?
  39. subs = @employee.subordinates.active.order(last_name: :asc)
  40. render json: {
  41. data: subs.map { |e| employee_json(e) },
  42. meta: { total: subs.count }
  43. }
  44. end
  45. # GET /api/v1/hr/employees/:id/vacation_balance
  46. def vacation_balance
  47. authorize @employee, :show_balance?
  48. render json: {
  49. data: {
  50. employee_id: @employee.uuid,
  51. accrued_days: @employee.accrued_vacation_days,
  52. used_days: @employee.total_used_vacation_days,
  53. scheduled_days: @employee.scheduled_vacation_days,
  54. enjoyed_days: @employee.enjoyed_vacation_days,
  55. pending_days: pending_vacation_days(@employee),
  56. available_days: @employee.available_vacation_days
  57. }
  58. }
  59. end
  60. # POST /api/v1/hr/employees
  61. def create
  62. authorize ::Hr::Employee
  63. @employee = ::Hr::Employee.new(employee_create_params)
  64. @employee.organization = current_organization
  65. if @employee.save
  66. render json: {
  67. data: employee_json(@employee, detailed: true),
  68. message: "Empleado creado exitosamente"
  69. }, status: :created
  70. else
  71. render json: {
  72. error: @employee.errors.full_messages.join(", ")
  73. }, status: :unprocessable_content
  74. end
  75. end
  76. # POST /api/v1/hr/employees/:id/create_account
  77. def create_account
  78. authorize @employee, :create_account?
  79. service = ::Hr::EmployeeAccountService.new(@employee)
  80. if service.has_account?
  81. return render json: {
  82. error: "El empleado ya tiene una cuenta de usuario"
  83. }, status: :unprocessable_content
  84. end
  85. user = service.create_account!
  86. if user
  87. render json: {
  88. data: employee_json(@employee.reload, detailed: true),
  89. user: {
  90. id: user.uuid,
  91. email: user.email,
  92. must_change_password: user.must_change_password
  93. },
  94. message: "Cuenta de usuario creada exitosamente. El usuario debe cambiar su contraseña en el primer inicio de sesión."
  95. }
  96. else
  97. render json: {
  98. error: service.errors.join(", ")
  99. }, status: :unprocessable_content
  100. end
  101. end
  102. # GET /api/v1/hr/employees/org_chart
  103. # Returns hierarchical organization chart data
  104. def org_chart
  105. authorize ::Hr::Employee, :index?
  106. employees = ::Hr::Employee
  107. .where(organization_id: current_organization.id)
  108. .active
  109. .order(last_name: :asc)
  110. # Build tree structure
  111. tree = build_org_tree(employees)
  112. render json: {
  113. data: tree,
  114. meta: {
  115. total_employees: employees.count,
  116. top_level_count: tree.count
  117. }
  118. }
  119. end
  120. # POST /api/v1/hr/employees/:id/generate_document
  121. def generate_document
  122. authorize @employee, :update?
  123. template = ::Templates::Template.find_by!(uuid: params[:template_id])
  124. unless template.active?
  125. return render json: { error: "El template no está activo" }, status: :unprocessable_content
  126. end
  127. context = {
  128. employee: @employee,
  129. organization: current_organization,
  130. user: current_user
  131. }
  132. begin
  133. # Use robust service for better variable replacement and formatting
  134. service = ::Templates::RobustDocumentGeneratorService.new(template, context)
  135. generated_doc = service.generate!
  136. render json: {
  137. data: {
  138. id: generated_doc.uuid,
  139. name: generated_doc.name,
  140. status: generated_doc.status,
  141. created_at: generated_doc.created_at.iso8601
  142. },
  143. message: "Documento generado exitosamente"
  144. }, status: :created
  145. rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
  146. render json: {
  147. error: "No se puede generar el documento. Faltan datos requeridos.",
  148. missing_variables: e.message,
  149. action_required: "complete_employee_data"
  150. }, status: :unprocessable_entity
  151. rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
  152. render json: { error: e.message }, status: :unprocessable_entity
  153. rescue StandardError => e
  154. Rails.logger.error "Error generating document: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
  155. render json: { error: "Error al generar el documento: #{e.message}" }, status: :internal_server_error
  156. end
  157. end
  158. private
  159. def set_employee
  160. @employee = ::Hr::Employee.find_by!(uuid: params[:id])
  161. rescue Mongoid::Errors::DocumentNotFound
  162. render json: { error: "Employee not found" }, status: :not_found
  163. end
  164. def employee_params
  165. permitted = params.require(:employee).permit(
  166. :first_name,
  167. :last_name,
  168. :employee_number,
  169. :employment_status,
  170. :employment_type,
  171. :hire_date,
  172. :termination_date,
  173. :job_title,
  174. :department,
  175. :cost_center,
  176. :date_of_birth,
  177. :emergency_contact_name,
  178. :emergency_contact_phone,
  179. :supervisor_id,
  180. # Contract fields
  181. :contract_type,
  182. :contract_template_id,
  183. :contract_start_date,
  184. :contract_end_date,
  185. :contract_duration_value,
  186. :contract_duration_unit,
  187. :trial_period_days,
  188. # Compensation fields
  189. :salary,
  190. :food_allowance,
  191. :transport_allowance,
  192. :payment_frequency,
  193. :work_city,
  194. # Personal identification
  195. :identification_type,
  196. :identification_number,
  197. :place_of_birth,
  198. :nationality,
  199. :address,
  200. :phone,
  201. :personal_email
  202. )
  203. # Convert supervisor_id from UUID to internal ID
  204. if permitted[:supervisor_id].present?
  205. supervisor = ::Hr::Employee.find_by(uuid: permitted[:supervisor_id])
  206. permitted[:supervisor_id] = supervisor&.id
  207. end
  208. permitted
  209. end
  210. # Params for creating a new employee (includes name fields)
  211. def employee_create_params
  212. permitted = params.require(:employee).permit(
  213. :first_name,
  214. :last_name,
  215. :employee_number,
  216. :employment_status,
  217. :employment_type,
  218. :hire_date,
  219. :termination_date,
  220. :job_title,
  221. :department,
  222. :cost_center,
  223. :date_of_birth,
  224. :emergency_contact_name,
  225. :emergency_contact_phone,
  226. :supervisor_id,
  227. # Contract fields
  228. :contract_type,
  229. :contract_template_id,
  230. :contract_start_date,
  231. :contract_end_date,
  232. :contract_duration_value,
  233. :contract_duration_unit,
  234. :trial_period_days,
  235. # Compensation fields
  236. :salary,
  237. :food_allowance,
  238. :transport_allowance,
  239. :payment_frequency,
  240. :work_city,
  241. # Personal identification
  242. :identification_type,
  243. :identification_number,
  244. :place_of_birth,
  245. :nationality,
  246. :address,
  247. :phone,
  248. :personal_email
  249. )
  250. # Convert supervisor_id from UUID to internal ID
  251. if permitted[:supervisor_id].present?
  252. supervisor = ::Hr::Employee.find_by(uuid: permitted[:supervisor_id])
  253. permitted[:supervisor_id] = supervisor&.id
  254. end
  255. permitted
  256. end
  257. def apply_filters(scope)
  258. scope = scope.where(department: params[:department]) if params[:department].present?
  259. scope = scope.where(employment_status: params[:status]) if params[:status].present?
  260. scope = filter_by_supervisor(scope)
  261. filter_by_query(scope)
  262. end
  263. def filter_by_supervisor(scope)
  264. return scope if params[:supervisor_id].blank?
  265. supervisor = ::Hr::Employee.find_by(uuid: params[:supervisor_id])
  266. supervisor ? scope.where(supervisor_id: supervisor.id) : scope
  267. end
  268. def filter_by_query(scope)
  269. return scope if params[:q].blank?
  270. query = /#{Regexp.escape(params[:q])}/i
  271. scope.or({ first_name: query }, { last_name: query }, { employee_number: query })
  272. end
  273. def apply_sorting(scope)
  274. sort_column = params[:sort_by].presence || "last_name"
  275. sort_direction = params[:sort_direction]&.downcase == "desc" ? :desc : :asc
  276. # Map frontend column names to database fields
  277. column_map = {
  278. "full_name" => :last_name,
  279. "job_title" => :job_title,
  280. "department" => :department,
  281. "employment_status" => :employment_status,
  282. "hire_date" => :hire_date,
  283. "available_vacation_days" => :hire_date # Sort by hire_date as proxy for vacation days
  284. }
  285. db_column = column_map[sort_column] || :last_name
  286. # For full_name, add secondary sort by first_name
  287. if sort_column == "full_name"
  288. scope.order(last_name: sort_direction, first_name: sort_direction)
  289. else
  290. scope.order(db_column => sort_direction, last_name: :asc)
  291. end
  292. end
  293. def employee_json(employee, detailed: false) # rubocop:disable Metrics/MethodLength
  294. json = {
  295. id: employee.uuid,
  296. employee_number: employee.employee_number,
  297. first_name: employee.first_name,
  298. last_name: employee.last_name,
  299. full_name: employee.full_name,
  300. email: employee.user&.email,
  301. department: employee.department,
  302. job_title: employee.job_title,
  303. employment_status: employee.employment_status,
  304. hire_date: employee.hire_date&.iso8601,
  305. available_vacation_days: employee.available_vacation_days&.floor,
  306. supervisor_id: employee.supervisor&.uuid
  307. }
  308. if detailed
  309. json.merge!(
  310. employment_type: employee.employment_type,
  311. termination_date: employee.termination_date&.iso8601,
  312. cost_center: employee.cost_center,
  313. date_of_birth: employee.date_of_birth&.iso8601,
  314. emergency_contact_name: employee.emergency_contact_name,
  315. emergency_contact_phone: employee.emergency_contact_phone,
  316. supervisor: employee.supervisor ? employee_summary(employee.supervisor) : nil,
  317. supervisor_id: employee.supervisor&.uuid,
  318. is_supervisor: employee.supervisor?,
  319. is_hr_staff: employee.hr_staff?,
  320. is_hr_manager: employee.hr_manager?,
  321. vacation_balance_days: can_view_balance?(employee) ? employee.available_vacation_days : nil,
  322. # Contract fields
  323. contract_type: employee.contract_type,
  324. contract_template_id: employee.contract_template_id,
  325. contract_template_name: employee.contract_template&.name,
  326. contract_start_date: employee.contract_start_date&.iso8601,
  327. contract_end_date: employee.contract_end_date&.iso8601,
  328. contract_duration_value: employee.contract_duration_value,
  329. contract_duration_unit: employee.contract_duration_unit,
  330. trial_period_days: employee.trial_period_days,
  331. # Compensation fields
  332. salary: employee.salary&.to_f,
  333. food_allowance: employee.food_allowance&.to_f,
  334. transport_allowance: employee.transport_allowance&.to_f,
  335. payment_frequency: employee.payment_frequency,
  336. work_city: employee.work_city,
  337. # Personal identification
  338. identification_type: employee.identification_type,
  339. identification_number: employee.identification_number,
  340. place_of_birth: employee.place_of_birth,
  341. nationality: employee.nationality,
  342. address: employee.address,
  343. phone: employee.phone,
  344. personal_email: employee.personal_email,
  345. # Account status
  346. has_account: employee.user_id.present?,
  347. user_email: employee.user&.email
  348. )
  349. end
  350. json
  351. end
  352. def employee_summary(employee)
  353. {
  354. id: employee.uuid,
  355. name: employee.full_name,
  356. job_title: employee.job_title
  357. }
  358. end
  359. def pending_vacation_days(employee)
  360. ::Hr::VacationRequest
  361. .where(employee_id: employee.id)
  362. .where(:status.in => ["draft", "pending"])
  363. .sum(:days_requested) || 0
  364. end
  365. def can_view_balance?(employee)
  366. current_employee.id == employee.id ||
  367. current_employee.hr_staff? ||
  368. current_employee.supervises?(employee)
  369. end
  370. def build_org_tree(employees)
  371. # Group by supervisor_id for efficient lookup
  372. by_supervisor = employees.group_by(&:supervisor_id)
  373. # Find top-level employees (no supervisor or supervisor outside org)
  374. employee_ids = employees.pluck(:id).to_set
  375. top_level = employees.select do |e|
  376. e.supervisor_id.nil? || !employee_ids.include?(e.supervisor_id)
  377. end
  378. # Build tree recursively
  379. top_level.map { |e| build_node(e, by_supervisor) }
  380. end
  381. def build_node(employee, by_supervisor)
  382. children = by_supervisor[employee.id] || []
  383. {
  384. id: employee.uuid,
  385. name: employee.full_name,
  386. job_title: employee.job_title,
  387. department: employee.department,
  388. email: employee.user&.email,
  389. photo_url: nil, # Future: add avatar support
  390. subordinates_count: count_all_subordinates(employee, by_supervisor),
  391. children: children.sort_by(&:last_name).map { |c| build_node(c, by_supervisor) }
  392. }
  393. end
  394. def count_all_subordinates(employee, by_supervisor)
  395. direct = by_supervisor[employee.id] || []
  396. direct.count + direct.sum { |c| count_all_subordinates(c, by_supervisor) }
  397. end
  398. def current_employee
  399. @current_employee ||= ::Hr::Employee.for_user(current_user) ||
  400. ::Hr::Employee.create!(
  401. user_id: current_user.id,
  402. organization_id: current_user.organization_id,
  403. first_name: current_user.first_name,
  404. last_name: current_user.last_name,
  405. email: current_user.email,
  406. employee_number: "EMP-#{current_user.id.to_s[-6..]}"
  407. )
  408. end
  409. end
  410. end
  411. end
  412. end

app/controllers/api/v1/hr/vacations_controller.rb

0.0% lines covered

336 relevant lines. 0 lines covered and 336 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Hr
  5. # Vacation requests management for employees
  6. class VacationsController < BaseController
  7. before_action :set_vacation, only: [:show, :update, :destroy, :submit, :cancel, :generate_document, :download_document, :sign_document]
  8. # GET /api/v1/hr/vacations
  9. def index
  10. # Auto-marcar vacaciones pasadas como disfrutadas
  11. current_employee.vacation_requests.approved.where(:end_date.lt => Date.current).each do |v|
  12. v.mark_as_enjoyed!
  13. rescue ::Hr::VacationRequest::InvalidStateError
  14. next
  15. end
  16. @vacations = policy_scope(::Hr::VacationRequest)
  17. .order(created_at: :desc)
  18. @vacations = apply_filters(@vacations)
  19. @vacations = paginate(@vacations)
  20. render json: {
  21. data: @vacations.map { |v| vacation_json(v) },
  22. meta: pagination_meta(@vacations).merge(vacation_balance: vacation_balance_json)
  23. }
  24. end
  25. # GET /api/v1/hr/vacations/:id
  26. def show
  27. authorize @vacation
  28. render json: { data: vacation_json(@vacation, detailed: true), document: document_info }
  29. end
  30. # POST /api/v1/hr/vacations
  31. def create
  32. @vacation = ::Hr::VacationRequest.new(vacation_params)
  33. @vacation.employee = current_employee
  34. @vacation.organization = current_organization
  35. authorize @vacation
  36. if @vacation.save
  37. # Auto-generate document immediately after creation
  38. generate_vacation_document_if_available
  39. render json: {
  40. data: vacation_json(@vacation, detailed: true),
  41. document: @vacation.document_uuid ? document_info : nil
  42. }, status: :created
  43. else
  44. render json: { errors: @vacation.errors.full_messages }, status: :unprocessable_content
  45. end
  46. end
  47. # PATCH /api/v1/hr/vacations/:id
  48. def update
  49. authorize @vacation
  50. unless @vacation.draft?
  51. return render json: { error: "Can only update draft requests" }, status: :unprocessable_content
  52. end
  53. if @vacation.update(vacation_params)
  54. render json: { data: vacation_json(@vacation) }
  55. else
  56. render json: { errors: @vacation.errors.full_messages }, status: :unprocessable_content
  57. end
  58. end
  59. # POST /api/v1/hr/vacations/:id/submit
  60. def submit
  61. authorize @vacation, :submit?
  62. # Verify employee has signed the document
  63. if @vacation.document_uuid
  64. doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
  65. if doc && !employee_has_signed?(doc)
  66. return render json: {
  67. error: "Debes firmar el documento antes de enviar la solicitud"
  68. }, status: :unprocessable_content
  69. end
  70. end
  71. @vacation.submit!(actor: current_employee)
  72. render json: {
  73. data: vacation_json(@vacation, detailed: true),
  74. message: "Solicitud de vacaciones enviada para aprobación"
  75. }
  76. rescue ::Hr::VacationRequest::InvalidStateError,
  77. ::Hr::VacationRequest::ValidationError => e
  78. render json: { error: e.message }, status: :unprocessable_content
  79. end
  80. # POST /api/v1/hr/vacations/:id/sign_document
  81. def sign_document
  82. authorize @vacation, :show?
  83. unless @vacation.document_uuid
  84. return render json: { error: "No hay documento para firmar" }, status: :not_found
  85. end
  86. generated_doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
  87. unless generated_doc
  88. return render json: { error: "Documento no encontrado" }, status: :not_found
  89. end
  90. # Get user's signature
  91. signature = current_user.signatures.find_by(is_default: true) || current_user.signatures.first
  92. unless signature
  93. return render json: { error: "No tienes una firma configurada. Ve a tu perfil para crear una." }, status: :unprocessable_content
  94. end
  95. # Find the employee signatory slot
  96. employee_sig = generated_doc.signatures.find { |s| s["signatory_type_code"] == "employee" }
  97. unless employee_sig
  98. return render json: { error: "No hay espacio de firma para empleado en este documento" }, status: :unprocessable_content
  99. end
  100. if employee_sig["signed_at"].present?
  101. return render json: { error: "Ya has firmado este documento" }, status: :unprocessable_content
  102. end
  103. # Sign the document
  104. generated_doc.sign!(user: current_user, signature: signature)
  105. render json: {
  106. data: vacation_json(@vacation, detailed: true),
  107. document: document_info,
  108. message: "Documento firmado exitosamente"
  109. }
  110. rescue StandardError => e
  111. Rails.logger.error("Error signing vacation document: #{e.message}")
  112. render json: { error: "Error al firmar: #{e.message}" }, status: :unprocessable_content
  113. end
  114. # POST /api/v1/hr/vacations/:id/cancel
  115. def cancel
  116. authorize @vacation, :cancel?
  117. @vacation.cancel!(actor: current_employee, reason: params[:reason])
  118. render json: {
  119. data: vacation_json(@vacation),
  120. message: "Vacation request cancelled"
  121. }
  122. rescue ::Hr::VacationRequest::InvalidStateError => e
  123. render json: { error: e.message }, status: :unprocessable_content
  124. rescue ::Hr::VacationRequest::AuthorizationError => e
  125. render json: { error: e.message }, status: :forbidden
  126. end
  127. # DELETE /api/v1/hr/vacations/:id
  128. def destroy
  129. authorize @vacation, :destroy?
  130. # Check if vacation can be deleted
  131. unless can_delete_vacation?
  132. return render json: {
  133. error: "No puedes eliminar esta solicitud. Solo se pueden eliminar solicitudes que no hayan sido firmadas o autorizadas por otros."
  134. }, status: :forbidden
  135. end
  136. # Delete associated document if exists
  137. if @vacation.document_uuid
  138. doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
  139. doc&.destroy
  140. end
  141. @vacation.destroy
  142. render json: { message: "Solicitud de vacaciones eliminada exitosamente" }
  143. end
  144. # POST /api/v1/hr/vacations/:id/generate_document
  145. def generate_document
  146. authorize @vacation, :show?
  147. # Find vacation template
  148. template = find_vacation_template
  149. unless template
  150. return render json: {
  151. error: "No hay template activo para solicitud de vacaciones"
  152. }, status: :not_found
  153. end
  154. # Build context for variable resolution
  155. context = {
  156. employee: @vacation.employee,
  157. organization: current_organization,
  158. request: @vacation
  159. }
  160. # Generate document
  161. generator = ::Templates::RobustDocumentGeneratorService.new(template, context)
  162. generated_doc = generator.generate!
  163. # Link document to vacation request
  164. @vacation.update!(document_uuid: generated_doc.uuid)
  165. render json: {
  166. data: vacation_json(@vacation, detailed: true),
  167. document: generated_document_json(generated_doc),
  168. message: "Documento generado exitosamente"
  169. }
  170. rescue StandardError => e
  171. Rails.logger.error("Error generating vacation document: #{e.message}")
  172. render json: { error: "Error al generar documento: #{e.message}" }, status: :unprocessable_content
  173. end
  174. # GET /api/v1/hr/vacations/:id/download_document
  175. def download_document
  176. authorize @vacation, :show?
  177. unless @vacation.document_uuid
  178. return render json: { error: "No hay documento generado" }, status: :not_found
  179. end
  180. generated_doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
  181. unless generated_doc
  182. return render json: { error: "Documento no encontrado" }, status: :not_found
  183. end
  184. # Get PDF content from GridFS
  185. pdf_content = generated_doc.file_content
  186. unless pdf_content
  187. return render json: { error: "Archivo PDF no encontrado" }, status: :not_found
  188. end
  189. send_data pdf_content,
  190. type: "application/pdf",
  191. filename: "solicitud_vacaciones_#{@vacation.request_number}.pdf",
  192. disposition: "inline"
  193. end
  194. private
  195. def set_vacation
  196. @vacation = ::Hr::VacationRequest.find_by!(uuid: params[:id])
  197. rescue Mongoid::Errors::DocumentNotFound
  198. render json: { error: "Vacation request not found" }, status: :not_found
  199. end
  200. def vacation_params
  201. params.require(:vacation).permit(
  202. :vacation_type,
  203. :start_date,
  204. :end_date,
  205. :days_requested,
  206. :reason,
  207. :notes
  208. )
  209. end
  210. def apply_filters(scope) # rubocop:disable Metrics/AbcSize
  211. scope = scope.where(status: params[:status]) if params[:status].present?
  212. scope = scope.where(vacation_type: params[:type]) if params[:type].present?
  213. scope = scope.where(:start_date.gte => params[:from]) if params[:from].present?
  214. scope = scope.where(:end_date.lte => params[:to]) if params[:to].present?
  215. scope
  216. end
  217. def vacation_json(vacation, detailed: false) # rubocop:disable Metrics/MethodLength
  218. # Check if document exists and needs employee signature
  219. needs_signature = false
  220. if vacation.document_uuid.present?
  221. doc = ::Templates::GeneratedDocument.find_by(uuid: vacation.document_uuid)
  222. needs_signature = doc && !employee_has_signed?(doc)
  223. end
  224. json = {
  225. id: vacation.uuid,
  226. request_number: vacation.request_number,
  227. vacation_type: vacation.vacation_type,
  228. start_date: vacation.start_date&.iso8601,
  229. end_date: vacation.end_date&.iso8601,
  230. days_requested: vacation.days_requested,
  231. status: vacation.status,
  232. status_label: vacation.status_label,
  233. submitted_at: vacation.submitted_at&.iso8601,
  234. created_at: vacation.created_at.iso8601,
  235. has_document: vacation.document_uuid.present?,
  236. needs_employee_signature: needs_signature,
  237. can_delete: can_delete_for_user?(vacation)
  238. }
  239. if detailed
  240. json.merge!(
  241. reason: vacation.reason,
  242. notes: vacation.notes,
  243. decided_at: vacation.decided_at&.iso8601,
  244. decision_reason: vacation.decision_reason,
  245. approved_by_name: vacation.approved_by_name,
  246. approver: vacation.approver ? employee_summary(vacation.approver) : nil,
  247. document_uuid: vacation.document_uuid,
  248. history: vacation.history
  249. )
  250. end
  251. json
  252. end
  253. def employee_summary(employee)
  254. {
  255. id: employee.uuid,
  256. name: employee.full_name,
  257. email: employee.user&.email
  258. }
  259. end
  260. def vacation_balance_json
  261. emp = current_employee
  262. {
  263. accrued: emp.accrued_vacation_days,
  264. scheduled: emp.scheduled_vacation_days,
  265. enjoyed: emp.enjoyed_vacation_days,
  266. total_used: emp.total_used_vacation_days,
  267. available: emp.available_vacation_days
  268. }
  269. end
  270. def current_employee
  271. @current_employee ||= ::Hr::Employee.for_user(current_user) ||
  272. ::Hr::Employee.create!(
  273. user: current_user,
  274. organization: current_organization,
  275. job_title: current_user.title,
  276. department: current_user.department,
  277. hire_date: Date.current,
  278. vacation_balance_days: 15.0
  279. )
  280. end
  281. def find_vacation_template
  282. ::Templates::Template.where(
  283. organization_id: current_organization.id,
  284. category: "vacation",
  285. status: "active"
  286. ).first
  287. end
  288. def generate_vacation_document_if_available
  289. template = find_vacation_template
  290. return unless template
  291. context = {
  292. employee: @vacation.employee,
  293. organization: current_organization,
  294. request: @vacation,
  295. user: current_user
  296. }
  297. generator = ::Templates::RobustDocumentGeneratorService.new(template, context)
  298. generated_doc = generator.generate!
  299. @vacation.update!(document_uuid: generated_doc.uuid)
  300. rescue StandardError => e
  301. # Log but don't fail the submit if document generation fails
  302. Rails.logger.error("Error auto-generating vacation document: #{e.message}")
  303. Rails.logger.error(e.backtrace.first(5).join("\n"))
  304. end
  305. def generated_document_json(doc)
  306. {
  307. uuid: doc.uuid,
  308. name: doc.name,
  309. status: doc.status,
  310. has_pdf: doc.draft_file_id.present? || doc.final_file_id.present?,
  311. created_at: doc.created_at.iso8601
  312. }
  313. end
  314. def document_info
  315. return nil unless @vacation.document_uuid
  316. doc = ::Templates::GeneratedDocument.find_by(uuid: @vacation.document_uuid)
  317. return nil unless doc
  318. {
  319. uuid: doc.uuid,
  320. name: doc.name,
  321. status: doc.status,
  322. has_pdf: doc.draft_file_id.present? || doc.final_file_id.present?,
  323. employee_signed: employee_has_signed?(doc),
  324. signatures: doc.signatures.map do |sig|
  325. {
  326. signatory_type_code: sig["signatory_type_code"],
  327. label: sig["label"],
  328. signed: sig["signed_at"].present?,
  329. signed_at: sig["signed_at"],
  330. signed_by: sig["signed_by_name"]
  331. }
  332. end
  333. }
  334. end
  335. def employee_has_signed?(doc)
  336. employee_sig = doc.signatures.find { |s| s["signatory_type_code"] == "employee" }
  337. employee_sig && employee_sig["signed_at"].present?
  338. end
  339. # Check if vacation can be deleted (for action)
  340. # Admin/HR can delete any request, owner has restrictions
  341. def can_delete_vacation?
  342. can_delete_for_user?(@vacation)
  343. end
  344. # Check if current user can delete this vacation (for JSON response)
  345. def can_delete_for_user?(vacation)
  346. # Admin and HR can delete any vacation
  347. return true if current_user.admin? || current_employee&.hr_manager?
  348. can_delete_vacation_for?(vacation)
  349. end
  350. def can_delete_vacation_for?(vacation)
  351. # For owner: check restrictions
  352. return false unless vacation.employee_id == current_employee&.id
  353. # Cannot delete if already approved, enjoyed, or in certain final states
  354. return false if vacation.approved? || vacation.enjoyed?
  355. # If there's a document, check that no one else has signed
  356. if vacation.document_uuid
  357. doc = ::Templates::GeneratedDocument.find_by(uuid: vacation.document_uuid)
  358. if doc
  359. # Check for any signatures from non-employee signatories
  360. other_signatures = doc.signatures.select do |sig|
  361. sig["signatory_type_code"] != "employee" && sig["signed_at"].present?
  362. end
  363. return false if other_signatures.any?
  364. end
  365. end
  366. true
  367. end
  368. end
  369. end
  370. end
  371. end

app/controllers/api/v1/legal/contract_approvals_controller.rb

0.0% lines covered

247 relevant lines. 0 lines covered and 247 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Legal
  5. class ContractApprovalsController < BaseController
  6. before_action :set_contract, only: %i[show approve reject sign]
  7. # GET /api/v1/legal/contract_approvals
  8. def index
  9. status_filter = params[:status] || "pending"
  10. contracts = ::Legal::Contract
  11. .where(organization_id: current_organization.id)
  12. .includes(:third_party, :requested_by)
  13. if status_filter == "pending"
  14. # Get contracts pending approval that user can approve
  15. pending_approvals = contracts.pending_approval.select { |c| c.can_approve?(current_user) }
  16. # Also get contracts pending signatures that user can sign
  17. pending_signatures = contracts.pending_signatures.select { |c| can_sign_contract?(c) }
  18. contracts = pending_approvals + pending_signatures
  19. elsif status_filter == "signatures"
  20. # Only pending signatures
  21. contracts = contracts.pending_signatures.select { |c| can_sign_contract?(c) }
  22. else
  23. # History - approved/rejected by this user or all for admins
  24. if current_user.admin? || current_user.has_role?("legal")
  25. contracts = contracts.any_of(
  26. { status: "approved" },
  27. { status: "rejected" },
  28. { status: "active" },
  29. { status: "terminated" },
  30. { status: "cancelled" }
  31. ).order(updated_at: :desc).limit(50).to_a
  32. else
  33. # Filter to contracts where user approved or signed
  34. contracts = contracts.any_of(
  35. { status: "approved" },
  36. { status: "rejected" },
  37. { status: "active" }
  38. ).order(updated_at: :desc).limit(50).to_a.select do |c|
  39. c.approvals.any? { |a| a.approver_id == current_user.id.to_s } ||
  40. (c.generated_document&.signatures&.any? { |s| s["user_id"] == current_user.id.to_s && s["status"] == "signed" })
  41. end
  42. end
  43. end
  44. # Calculate stats
  45. pending_approval_count = ::Legal::Contract
  46. .where(organization_id: current_organization.id)
  47. .pending_approval
  48. .count { |c| c.can_approve?(current_user) }
  49. pending_signatures_count = ::Legal::Contract
  50. .where(organization_id: current_organization.id)
  51. .pending_signatures
  52. .count { |c| can_sign_contract?(c) }
  53. render json: {
  54. data: contracts.map { |c| approval_json(c) },
  55. meta: {
  56. status: status_filter,
  57. total_pending: pending_approval_count + pending_signatures_count,
  58. total_pending_approvals: pending_approval_count,
  59. total_pending_signatures: pending_signatures_count
  60. }
  61. }
  62. end
  63. # GET /api/v1/legal/contract_approvals/:id
  64. def show
  65. render json: { data: approval_json(@contract, detailed: true) }
  66. end
  67. # POST /api/v1/legal/contract_approvals/:id/approve
  68. def approve
  69. role = determine_user_role
  70. unless role
  71. return render json: { error: "No tienes un rol de aprobación válido" }, status: :forbidden
  72. end
  73. @contract.approve!(actor: current_user, role: role, notes: params[:notes])
  74. render json: {
  75. data: approval_json(@contract),
  76. message: all_approved? ? "Contrato aprobado completamente" : "Aprobación registrada, pendiente siguiente nivel"
  77. }
  78. rescue ::Legal::Contract::InvalidStateError, ::Legal::Contract::AuthorizationError => e
  79. render json: { error: e.message }, status: :unprocessable_entity
  80. end
  81. # POST /api/v1/legal/contract_approvals/:id/reject
  82. def reject
  83. role = determine_user_role
  84. unless role
  85. return render json: { error: "No tienes un rol de aprobación válido" }, status: :forbidden
  86. end
  87. unless params[:reason].present?
  88. return render json: { error: "Se requiere un motivo de rechazo" }, status: :unprocessable_entity
  89. end
  90. @contract.reject!(actor: current_user, role: role, reason: params[:reason])
  91. render json: {
  92. data: approval_json(@contract),
  93. message: "Contrato rechazado"
  94. }
  95. rescue ::Legal::Contract::InvalidStateError, ::Legal::Contract::AuthorizationError, ArgumentError => e
  96. render json: { error: e.message }, status: :unprocessable_entity
  97. end
  98. # POST /api/v1/legal/contract_approvals/:id/sign
  99. # Sign the contract document
  100. def sign
  101. unless @contract.pending_signatures?
  102. return render json: { error: "Este contrato no está pendiente de firmas" }, status: :unprocessable_entity
  103. end
  104. doc = @contract.generated_document
  105. unless doc
  106. return render json: { error: "Este contrato no tiene documento generado" }, status: :not_found
  107. end
  108. unless doc.can_be_signed_by?(current_user)
  109. return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
  110. end
  111. # Get user's default signature
  112. signature = current_user.signatures.active.default_signature.first || current_user.signatures.active.first
  113. unless signature
  114. return render json: { error: "No tienes una firma digital configurada. Configura tu firma en tu perfil." }, status: :unprocessable_entity
  115. end
  116. # Get custom position from params if provided
  117. custom_position = nil
  118. if params[:signature_position].present?
  119. custom_position = {
  120. x: params[:signature_position][:x_position]&.to_i,
  121. y: params[:signature_position][:y_position]&.to_i,
  122. width: params[:signature_position][:width]&.to_i,
  123. height: params[:signature_position][:height]&.to_i
  124. }.compact
  125. custom_position = nil if custom_position.empty?
  126. end
  127. doc.sign!(user: current_user, signature: signature, custom_position: custom_position)
  128. # Refresh document to get updated signature status
  129. doc.reload
  130. # If all signatures complete, mark contract as approved
  131. if doc.all_required_signed? && @contract.pending_signatures?
  132. @contract.complete_signatures!(actor: current_user)
  133. end
  134. @contract.reload
  135. render json: {
  136. data: approval_json(@contract),
  137. message: "Documento firmado exitosamente",
  138. all_signed: doc.all_required_signed?
  139. }
  140. rescue ::Templates::GeneratedDocument::SignatureError => e
  141. render json: { error: e.message }, status: :unprocessable_entity
  142. end
  143. private
  144. def set_contract
  145. @contract = ::Legal::Contract.find_by(uuid: params[:id])
  146. render json: { error: "Contrato no encontrado" }, status: :not_found unless @contract
  147. end
  148. def determine_user_role
  149. # Determine which approval role the user can act as
  150. return @contract.current_approver_role if @contract.can_approve?(current_user)
  151. # Check if user has any approval role
  152. roles_map = {
  153. "admin" => %w[area_manager legal general_manager ceo],
  154. "ceo" => %w[ceo],
  155. "general_manager" => %w[general_manager],
  156. "legal" => %w[legal],
  157. "manager" => %w[area_manager]
  158. }
  159. user_roles = current_user.roles || []
  160. user_roles.each do |user_role|
  161. approval_roles = roles_map[user_role] || []
  162. return @contract.current_approver_role if approval_roles.include?(@contract.current_approver_role)
  163. end
  164. nil
  165. end
  166. def all_approved?
  167. @contract.approved?
  168. end
  169. def can_sign_contract?(contract)
  170. return false unless contract.pending_signatures?
  171. doc = contract.generated_document
  172. return false unless doc
  173. doc.can_be_signed_by?(current_user)
  174. end
  175. def approval_json(contract, detailed: false)
  176. doc = contract.generated_document
  177. # Build signatures info
  178. signatures_info = []
  179. can_sign = false
  180. if doc
  181. signatures_info = doc.signatures.map do |sig|
  182. # Get signatory from template to get position info
  183. signatory_uuid = sig["signatory_id"]
  184. signatory = signatory_uuid.present? ? doc.template&.signatories&.where(uuid: signatory_uuid)&.first : nil
  185. sig_box = signatory&.signature_box || {}
  186. {
  187. signatory_label: sig["signatory_label"],
  188. signatory_type_code: sig["signatory_type_code"],
  189. user_name: sig["user_name"],
  190. user_id: sig["user_id"],
  191. status: sig["status"],
  192. required: sig["required"],
  193. signed_at: sig["signed_at"],
  194. signed_by_name: sig["signed_by_name"],
  195. is_mine: sig["user_id"] == current_user.id.to_s,
  196. # Position info for visual editor
  197. x_position: sig_box[:x] || 350,
  198. y_position: sig_box[:y] || 700,
  199. width: sig_box[:width] || 200,
  200. height: sig_box[:height] || 80,
  201. page_number: sig_box[:page] || 1
  202. }
  203. end
  204. # Only allow signing if contract is in pending_signatures status
  205. can_sign = contract.pending_signatures? && doc.can_be_signed_by?(current_user)
  206. end
  207. data = {
  208. id: contract.uuid,
  209. contract_number: contract.contract_number,
  210. title: contract.title,
  211. contract_type: contract.contract_type,
  212. type_label: contract.type_label,
  213. status: contract.status,
  214. status_label: contract.status_label,
  215. amount: contract.amount.to_f,
  216. currency: contract.currency,
  217. start_date: contract.start_date,
  218. end_date: contract.end_date,
  219. approval_level: contract.approval_level,
  220. approval_level_label: contract.approval_level_label,
  221. current_approver_role: contract.current_approver_role,
  222. current_approver_label: contract.current_approver_label,
  223. approval_progress: contract.approval_progress,
  224. can_approve: contract.can_approve?(current_user),
  225. can_sign: can_sign,
  226. submitted_at: contract.submitted_at,
  227. third_party: contract.third_party ? {
  228. id: contract.third_party.uuid,
  229. display_name: contract.third_party.display_name,
  230. type: contract.third_party.third_party_type
  231. } : nil,
  232. requested_by: contract.requested_by ? {
  233. id: contract.requested_by.uuid,
  234. name: contract.requested_by.full_name
  235. } : nil,
  236. has_document: contract.document_uuid.present?,
  237. document_page_count: doc&.template&.pdf_page_count || 1,
  238. pdf_width: doc&.template&.pdf_width || 612,
  239. pdf_height: doc&.template&.pdf_height || 792,
  240. approvals: contract.approvals.order(order: :asc).map { |a|
  241. {
  242. role: a.role,
  243. role_label: a.role_label,
  244. status: a.status,
  245. order: a.order,
  246. decided_at: a.decided_at,
  247. approver_name: a.approver_name,
  248. notes: a.notes,
  249. reason: a.reason
  250. }
  251. },
  252. document_signatures: signatures_info,
  253. document_signatures_status: contract.document_signatures_status
  254. }
  255. if detailed
  256. data.merge!(
  257. description: contract.description,
  258. payment_terms: contract.payment_terms,
  259. approved_at: contract.approved_at,
  260. rejected_at: contract.rejected_at,
  261. rejection_reason: contract.rejection_reason,
  262. history: contract.history.last(20)
  263. )
  264. end
  265. data
  266. end
  267. end
  268. end
  269. end
  270. end

app/controllers/api/v1/legal/contracts_controller.rb

0.0% lines covered

428 relevant lines. 0 lines covered and 428 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Legal
  5. class ContractsController < BaseController
  6. before_action :set_contract, only: %i[show update destroy submit activate terminate cancel archive unarchive generate_document download_document sign_document]
  7. # GET /api/v1/legal/contracts
  8. def index
  9. authorize ::Legal::Contract
  10. contracts = policy_scope(::Legal::Contract)
  11. .includes(:third_party, :requested_by)
  12. .order(created_at: :desc)
  13. # Archived filter - by default hide archived, show only archived when requested
  14. if params[:archived] == "true"
  15. contracts = contracts.archived
  16. elsif params[:include_archived] != "true"
  17. contracts = contracts.not_archived
  18. end
  19. # Filters
  20. contracts = contracts.by_type(params[:type]) if params[:type].present?
  21. contracts = contracts.where(status: params[:status]) if params[:status].present? && params[:status] != "archived"
  22. contracts = contracts.by_third_party(params[:third_party_id]) if params[:third_party_id].present?
  23. contracts = contracts.search(params[:search]) if params[:search].present?
  24. # Pagination
  25. page = (params[:page] || 1).to_i
  26. per_page = (params[:per_page] || 20).to_i
  27. total = contracts.count
  28. contracts = contracts.skip((page - 1) * per_page).limit(per_page)
  29. render json: {
  30. data: contracts.map { |c| contract_json(c) },
  31. meta: {
  32. current_page: page,
  33. per_page: per_page,
  34. total_count: total,
  35. total_pages: (total.to_f / per_page).ceil
  36. }
  37. }
  38. end
  39. # GET /api/v1/legal/contracts/:id
  40. def show
  41. authorize @contract
  42. render json: { data: contract_json(@contract, detailed: true) }
  43. end
  44. # POST /api/v1/legal/contracts
  45. def create
  46. authorize ::Legal::Contract
  47. @contract = ::Legal::Contract.new(contract_params)
  48. @contract.organization = current_organization
  49. @contract.requested_by = current_user
  50. if @contract.save
  51. render json: { data: contract_json(@contract) }, status: :created
  52. else
  53. render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
  54. end
  55. end
  56. # PATCH /api/v1/legal/contracts/:id
  57. def update
  58. authorize @contract
  59. if @contract.update(contract_params)
  60. render json: { data: contract_json(@contract) }
  61. else
  62. render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
  63. end
  64. end
  65. # DELETE /api/v1/legal/contracts/:id
  66. def destroy
  67. authorize @contract
  68. # Also delete associated generated document if exists
  69. if @contract.document_uuid
  70. doc = ::Templates::GeneratedDocument.find_by(uuid: @contract.document_uuid)
  71. doc&.destroy
  72. end
  73. if @contract.destroy
  74. render json: { message: "Contrato eliminado correctamente" }
  75. else
  76. render json: { errors: @contract.errors.full_messages }, status: :unprocessable_entity
  77. end
  78. end
  79. # POST /api/v1/legal/contracts/:id/submit
  80. def submit
  81. authorize @contract
  82. # Validate contract has required data
  83. unless @contract.third_party
  84. return render json: { error: "El contrato debe tener un tercero asignado" }, status: :unprocessable_entity
  85. end
  86. unless @contract.amount && @contract.amount > 0
  87. return render json: { error: "El contrato debe tener un monto válido" }, status: :unprocessable_entity
  88. end
  89. unless @contract.start_date && @contract.end_date
  90. return render json: { error: "El contrato debe tener fechas de inicio y fin" }, status: :unprocessable_entity
  91. end
  92. # If contract has a template, generate document first to validate variables
  93. if @contract.template_id && !@contract.document_uuid
  94. template = ::Templates::Template.find_by(uuid: @contract.template_id)
  95. if template
  96. context = {
  97. third_party: @contract.third_party,
  98. contract: @contract,
  99. organization: current_organization,
  100. user: current_user
  101. }
  102. begin
  103. service = ::Templates::RobustDocumentGeneratorService.new(template, context)
  104. doc = service.generate!
  105. @contract.update!(document_uuid: doc.uuid)
  106. rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
  107. return render json: {
  108. error: "No se puede enviar a aprobación. Faltan datos para generar el documento.",
  109. missing_variables: e.message,
  110. action_required: "complete_data"
  111. }, status: :unprocessable_entity
  112. rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
  113. return render json: {
  114. error: "Error al generar el documento: #{e.message}",
  115. action_required: "fix_template"
  116. }, status: :unprocessable_entity
  117. end
  118. end
  119. end
  120. @contract.submit!(actor: current_user)
  121. render json: {
  122. data: contract_json(@contract),
  123. message: "Contrato enviado a aprobación",
  124. document_generated: @contract.document_uuid.present?
  125. }
  126. rescue ::Legal::Contract::InvalidStateError, ::Legal::Contract::ValidationError => e
  127. render json: { error: e.message }, status: :unprocessable_entity
  128. end
  129. # POST /api/v1/legal/contracts/:id/activate
  130. def activate
  131. authorize @contract
  132. @contract.activate!(actor: current_user)
  133. render json: {
  134. data: contract_json(@contract),
  135. message: "Contrato activado"
  136. }
  137. rescue ::Legal::Contract::InvalidStateError => e
  138. render json: { error: e.message }, status: :unprocessable_entity
  139. end
  140. # POST /api/v1/legal/contracts/:id/terminate
  141. def terminate
  142. authorize @contract
  143. reason = params[:reason]
  144. @contract.terminate!(actor: current_user, reason: reason)
  145. render json: {
  146. data: contract_json(@contract),
  147. message: "Contrato terminado"
  148. }
  149. rescue ::Legal::Contract::InvalidStateError => e
  150. render json: { error: e.message }, status: :unprocessable_entity
  151. end
  152. # POST /api/v1/legal/contracts/:id/cancel
  153. def cancel
  154. authorize @contract
  155. reason = params[:reason]
  156. @contract.cancel!(actor: current_user, reason: reason)
  157. render json: {
  158. data: contract_json(@contract),
  159. message: "Contrato cancelado"
  160. }
  161. rescue ::Legal::Contract::InvalidStateError => e
  162. render json: { error: e.message }, status: :unprocessable_entity
  163. end
  164. # POST /api/v1/legal/contracts/:id/archive
  165. def archive
  166. authorize @contract
  167. @contract.archive!(actor: current_user)
  168. render json: {
  169. data: contract_json(@contract),
  170. message: "Contrato archivado"
  171. }
  172. rescue ::Legal::Contract::InvalidStateError => e
  173. render json: { error: e.message }, status: :unprocessable_entity
  174. end
  175. # POST /api/v1/legal/contracts/:id/unarchive
  176. def unarchive
  177. authorize @contract
  178. @contract.unarchive!(actor: current_user)
  179. render json: {
  180. data: contract_json(@contract),
  181. message: "Contrato restaurado del archivo"
  182. }
  183. rescue ::Legal::Contract::InvalidStateError => e
  184. render json: { error: e.message }, status: :unprocessable_entity
  185. end
  186. # POST /api/v1/legal/contracts/:id/generate_document
  187. def generate_document
  188. authorize @contract
  189. # Use provided template_id or fall back to contract's stored template
  190. template_id = params[:template_id].presence || @contract.template_id
  191. unless template_id
  192. return render json: { error: "No se especificó un template y el contrato no tiene uno asociado" }, status: :unprocessable_entity
  193. end
  194. template = ::Templates::Template.find_by(uuid: template_id)
  195. unless template
  196. return render json: { error: "Template no encontrado" }, status: :not_found
  197. end
  198. context = {
  199. third_party: @contract.third_party,
  200. contract: @contract,
  201. organization: current_organization,
  202. user: current_user
  203. }
  204. service = ::Templates::RobustDocumentGeneratorService.new(template, context)
  205. doc = service.generate!
  206. @contract.update!(document_uuid: doc.uuid)
  207. # Initialize signature workflow if template has signatories
  208. if template.signatories.any?
  209. doc.initialize_signatures!
  210. end
  211. render json: {
  212. data: contract_json(@contract),
  213. document: {
  214. uuid: doc.uuid,
  215. status: doc.status,
  216. pending_signatures: doc.pending_signatures_count,
  217. signatures: doc.signatures
  218. },
  219. message: "Documento generado correctamente"
  220. }
  221. rescue ::Templates::RobustDocumentGeneratorService::MissingVariablesError => e
  222. render json: {
  223. error: "Variables faltantes para generar el documento",
  224. missing_variables: e.message
  225. }, status: :unprocessable_entity
  226. rescue ::Templates::RobustDocumentGeneratorService::GenerationError => e
  227. render json: { error: e.message }, status: :unprocessable_entity
  228. end
  229. # POST /api/v1/legal/contracts/validate_template
  230. # Validates that all template variables can be resolved with the given data
  231. def validate_template
  232. authorize ::Legal::Contract
  233. template_id = params[:template_id]
  234. third_party_id = params[:third_party_id]
  235. unless template_id.present?
  236. return render json: { error: "Se requiere template_id" }, status: :unprocessable_entity
  237. end
  238. template = ::Templates::Template.find_by(uuid: template_id)
  239. unless template
  240. return render json: { error: "Template no encontrado" }, status: :not_found
  241. end
  242. third_party = nil
  243. if third_party_id.present?
  244. third_party = ::Legal::ThirdParty.find_by(uuid: third_party_id)
  245. unless third_party
  246. return render json: { error: "Tercero no encontrado" }, status: :not_found
  247. end
  248. end
  249. # Build a temporary contract object with the provided data
  250. temp_contract = ::Legal::Contract.new(
  251. title: params[:title] || "Validación",
  252. contract_type: params[:contract_type] || "services",
  253. amount: params[:amount]&.to_f,
  254. currency: params[:currency] || "COP",
  255. start_date: params[:start_date],
  256. end_date: params[:end_date],
  257. description: params[:description],
  258. payment_terms: params[:payment_terms],
  259. payment_frequency: params[:payment_frequency],
  260. organization: current_organization,
  261. third_party: third_party
  262. )
  263. # Create context for variable resolution
  264. context = {
  265. third_party: third_party,
  266. contract: temp_contract,
  267. organization: current_organization
  268. }
  269. # Validate variables
  270. resolver = ::Templates::VariableResolverService.new(context)
  271. validation = resolver.validate_for_template(template)
  272. # Group missing variables by source for better UX
  273. missing_by_source = validation[:missing].group_by { |m| m[:source] }
  274. render json: {
  275. valid: validation[:valid],
  276. template: {
  277. id: template.uuid,
  278. name: template.name,
  279. total_variables: validation[:total_variables]
  280. },
  281. validation: {
  282. resolved_count: validation[:resolved_count],
  283. missing_count: validation[:missing_count],
  284. missing: validation[:missing],
  285. missing_by_source: missing_by_source
  286. },
  287. message: validation[:valid] ? "Todos los datos están completos" : "Faltan datos requeridos para generar el documento"
  288. }
  289. end
  290. # GET /api/v1/legal/contracts/:id/download_document
  291. def download_document
  292. authorize @contract
  293. unless @contract.document_uuid
  294. return render json: { error: "Este contrato no tiene documento generado. Por favor genere el documento primero." }, status: :not_found
  295. end
  296. generated_doc = ::Templates::GeneratedDocument.find_by(uuid: @contract.document_uuid)
  297. unless generated_doc
  298. return render json: { error: "El documento asociado no fue encontrado. Por favor regenere el documento." }, status: :not_found
  299. end
  300. file_id = generated_doc.final_file_id || generated_doc.draft_file_id || generated_doc.original_draft_file_id
  301. unless file_id
  302. return render json: { error: "El archivo PDF no está disponible. Esto puede ocurrir si hubo un error durante la generación. Por favor regenere el documento." }, status: :unprocessable_entity
  303. end
  304. begin
  305. grid_file = Mongoid::GridFs.get(file_id)
  306. send_data grid_file.data,
  307. type: "application/pdf",
  308. disposition: "attachment",
  309. filename: generated_doc.file_name
  310. rescue Mongoid::Errors::DocumentNotFound
  311. render json: { error: "El archivo PDF no fue encontrado en el almacenamiento. Por favor regenere el documento." }, status: :not_found
  312. end
  313. end
  314. # POST /api/v1/legal/contracts/:id/sign_document
  315. # Signs the contract document with the current user's digital signature
  316. def sign_document
  317. authorize @contract
  318. unless @contract.pending_signatures?
  319. return render json: { error: "Este contrato no está pendiente de firmas" }, status: :unprocessable_entity
  320. end
  321. doc = @contract.generated_document
  322. unless doc
  323. return render json: { error: "Este contrato no tiene documento generado" }, status: :not_found
  324. end
  325. unless doc.can_be_signed_by?(current_user)
  326. return render json: { error: "No tienes firma pendiente en este documento" }, status: :forbidden
  327. end
  328. # Get user's default signature
  329. signature = current_user.signatures.active.default_signature.first || current_user.signatures.active.first
  330. unless signature
  331. return render json: { error: "No tienes una firma digital configurada. Configura tu firma en tu perfil." }, status: :unprocessable_entity
  332. end
  333. doc.sign!(user: current_user, signature: signature)
  334. # Refresh document to get updated signature status
  335. doc.reload
  336. # If all signatures complete and contract is still pending_signatures, mark complete
  337. if doc.all_required_signed? && @contract.pending_signatures?
  338. @contract.complete_signatures!(actor: current_user)
  339. end
  340. @contract.reload
  341. render json: {
  342. data: contract_json(@contract, detailed: true),
  343. message: "Documento firmado exitosamente",
  344. all_signed: doc.all_required_signed?
  345. }
  346. rescue ::Templates::GeneratedDocument::SignatureError => e
  347. render json: { error: e.message }, status: :unprocessable_entity
  348. end
  349. private
  350. def set_contract
  351. @contract = ::Legal::Contract.find_by(uuid: params[:id])
  352. render json: { error: "Contrato no encontrado" }, status: :not_found unless @contract
  353. end
  354. def contract_params
  355. params.require(:contract).permit(
  356. :title, :description, :contract_type,
  357. :start_date, :end_date, :signature_date,
  358. :amount, :currency, :payment_terms, :payment_frequency,
  359. :third_party_id, :template_id,
  360. :auto_renewal, :renewal_notice_days, :renewal_terms
  361. ).tap do |p|
  362. # Convert third_party_id from UUID to ObjectId
  363. if p[:third_party_id].present?
  364. tp = ::Legal::ThirdParty.find_by(uuid: p[:third_party_id])
  365. p[:third_party_id] = tp&.id
  366. end
  367. end
  368. end
  369. def contract_json(contract, detailed: false)
  370. data = {
  371. id: contract.uuid,
  372. contract_number: contract.contract_number,
  373. title: contract.title,
  374. contract_type: contract.contract_type,
  375. type_label: contract.type_label,
  376. status: contract.status,
  377. status_label: contract.status_label,
  378. amount: contract.amount.to_f,
  379. currency: contract.currency,
  380. start_date: contract.start_date,
  381. end_date: contract.end_date,
  382. duration_days: contract.duration_days,
  383. days_until_expiry: contract.days_until_expiry,
  384. expiring_soon: contract.expiring_soon?,
  385. approval_level: contract.approval_level,
  386. approval_level_label: contract.approval_level_label,
  387. current_approver_role: contract.current_approver_role,
  388. current_approver_label: contract.current_approver_label,
  389. approval_progress: contract.approval_progress,
  390. can_approve: contract.can_approve?(current_user),
  391. has_document: contract.document_uuid.present?,
  392. third_party: contract.third_party ? {
  393. id: contract.third_party.uuid,
  394. code: contract.third_party.code,
  395. display_name: contract.third_party.display_name,
  396. type: contract.third_party.third_party_type
  397. } : nil,
  398. requested_by: contract.requested_by ? {
  399. id: contract.requested_by.uuid,
  400. name: contract.requested_by.full_name
  401. } : nil,
  402. created_at: contract.created_at,
  403. updated_at: contract.updated_at,
  404. can_delete: ::Legal::ContractPolicy.new(current_user, contract).destroy?
  405. }
  406. if detailed
  407. doc = contract.generated_document
  408. doc_signatures = doc ? doc.signatures.map do |sig|
  409. {
  410. signatory_label: sig["signatory_label"],
  411. signatory_type_code: sig["signatory_type_code"],
  412. user_name: sig["user_name"],
  413. user_id: sig["user_id"],
  414. status: sig["status"],
  415. required: sig["required"],
  416. signed_at: sig["signed_at"],
  417. signed_by_name: sig["signed_by_name"]
  418. }
  419. end : []
  420. data.merge!(
  421. description: contract.description,
  422. signature_date: contract.signature_date,
  423. payment_terms: contract.payment_terms,
  424. payment_frequency: contract.payment_frequency,
  425. auto_renewal: contract.auto_renewal,
  426. renewal_notice_days: contract.renewal_notice_days,
  427. renewal_terms: contract.renewal_terms,
  428. submitted_at: contract.submitted_at,
  429. approved_at: contract.approved_at,
  430. rejected_at: contract.rejected_at,
  431. rejection_reason: contract.rejection_reason,
  432. document_uuid: contract.document_uuid,
  433. template_id: contract.template_id,
  434. approvals: contract.approvals.order(order: :asc).map { |a|
  435. {
  436. role: a.role,
  437. role_label: a.role_label,
  438. status: a.status,
  439. order: a.order,
  440. decided_at: a.decided_at,
  441. approver_name: a.approver_name,
  442. notes: a.notes,
  443. reason: a.reason
  444. }
  445. },
  446. document_signatures: doc_signatures,
  447. document_signatures_status: contract.document_signatures_status,
  448. can_sign_document: doc&.can_be_signed_by?(current_user) || false,
  449. history: contract.history.last(20),
  450. editable: contract.editable?,
  451. can_submit: contract.can_submit?,
  452. can_activate: contract.can_activate?,
  453. can_archive: ::Legal::ContractPolicy.new(current_user, contract).archive?,
  454. can_unarchive: ::Legal::ContractPolicy.new(current_user, contract).unarchive?
  455. )
  456. end
  457. data
  458. end
  459. end
  460. end
  461. end
  462. end

app/controllers/api/v1/legal/dashboard_controller.rb

0.0% lines covered

90 relevant lines. 0 lines covered and 90 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Legal
  5. class DashboardController < BaseController
  6. # GET /api/v1/legal/dashboard
  7. def show
  8. render json: {
  9. data: {
  10. third_parties: third_party_stats,
  11. contracts: contract_stats,
  12. approvals: approval_stats,
  13. expiring_soon: expiring_contracts
  14. }
  15. }
  16. end
  17. private
  18. def third_party_stats
  19. base = ::Legal::ThirdParty.where(organization_id: current_organization.id)
  20. {
  21. total: base.count,
  22. active: base.active.count,
  23. inactive: base.inactive.count,
  24. blocked: base.blocked.count,
  25. by_type: {
  26. providers: base.providers.count,
  27. clients: base.clients.count,
  28. contractors: base.contractors.count,
  29. partners: base.partners.count,
  30. other: base.where(third_party_type: "other").count
  31. }
  32. }
  33. end
  34. def contract_stats
  35. base = ::Legal::Contract.where(organization_id: current_organization.id)
  36. current_year = Time.current.year
  37. {
  38. total: base.count,
  39. draft: base.draft.count,
  40. pending_approval: base.pending_approval.count,
  41. approved: base.approved.count,
  42. active: base.active.count,
  43. expired: base.expired.count,
  44. rejected: base.rejected.count,
  45. by_type: ::Legal::Contract::TYPES.each_with_object({}) { |t, h|
  46. h[t] = base.by_type(t).count
  47. },
  48. created_this_year: base.where(:created_at.gte => Date.new(current_year, 1, 1)).count,
  49. total_value: {
  50. active: base.active.sum(:amount).to_f,
  51. pending: base.pending_approval.sum(:amount).to_f
  52. }
  53. }
  54. end
  55. def approval_stats
  56. base = ::Legal::Contract.where(organization_id: current_organization.id)
  57. pending = base.pending_approval.select { |c| c.can_approve?(current_user) }
  58. {
  59. pending_my_approval: pending.count,
  60. pending_total: base.pending_approval.count,
  61. by_level: {
  62. level_1: base.where(approval_level: "level_1", status: "pending_approval").count,
  63. level_2: base.where(approval_level: "level_2", status: "pending_approval").count,
  64. level_3: base.where(approval_level: "level_3", status: "pending_approval").count,
  65. level_4: base.where(approval_level: "level_4", status: "pending_approval").count
  66. }
  67. }
  68. end
  69. def expiring_contracts
  70. ::Legal::Contract
  71. .where(organization_id: current_organization.id)
  72. .active
  73. .where(:end_date.lte => Date.current + 30)
  74. .where(:end_date.gte => Date.current)
  75. .order(end_date: :asc)
  76. .limit(10)
  77. .map do |c|
  78. {
  79. id: c.uuid,
  80. contract_number: c.contract_number,
  81. title: c.title,
  82. third_party: c.third_party&.display_name,
  83. end_date: c.end_date,
  84. days_until_expiry: c.days_until_expiry,
  85. amount: c.amount.to_f
  86. }
  87. end
  88. end
  89. end
  90. end
  91. end
  92. end

app/controllers/api/v1/legal/third_parties_controller.rb

0.0% lines covered

155 relevant lines. 0 lines covered and 155 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Legal
  5. class ThirdPartiesController < BaseController
  6. before_action :set_third_party, only: %i[show update destroy activate deactivate block]
  7. # GET /api/v1/legal/third_parties
  8. def index
  9. authorize ::Legal::ThirdParty
  10. third_parties = policy_scope(::Legal::ThirdParty)
  11. .order(created_at: :desc)
  12. # Filters
  13. third_parties = third_parties.by_type(params[:type]) if params[:type].present?
  14. third_parties = third_parties.where(status: params[:status]) if params[:status].present?
  15. third_parties = third_parties.where(person_type: params[:person_type]) if params[:person_type].present?
  16. third_parties = third_parties.search(params[:search]) if params[:search].present?
  17. # Pagination
  18. page = (params[:page] || 1).to_i
  19. per_page = (params[:per_page] || 20).to_i
  20. total = third_parties.count
  21. third_parties = third_parties.skip((page - 1) * per_page).limit(per_page)
  22. render json: {
  23. data: third_parties.map { |tp| third_party_json(tp) },
  24. meta: {
  25. current_page: page,
  26. per_page: per_page,
  27. total_count: total,
  28. total_pages: (total.to_f / per_page).ceil
  29. }
  30. }
  31. end
  32. # GET /api/v1/legal/third_parties/:id
  33. def show
  34. authorize @third_party
  35. render json: { data: third_party_json(@third_party, detailed: true) }
  36. end
  37. # POST /api/v1/legal/third_parties
  38. def create
  39. authorize ::Legal::ThirdParty
  40. @third_party = ::Legal::ThirdParty.new(third_party_params)
  41. @third_party.organization = current_organization
  42. @third_party.created_by = current_user
  43. if @third_party.save
  44. render json: { data: third_party_json(@third_party) }, status: :created
  45. else
  46. render json: { errors: @third_party.errors.full_messages }, status: :unprocessable_entity
  47. end
  48. end
  49. # PATCH /api/v1/legal/third_parties/:id
  50. def update
  51. authorize @third_party
  52. if @third_party.update(third_party_params)
  53. render json: { data: third_party_json(@third_party) }
  54. else
  55. render json: { errors: @third_party.errors.full_messages }, status: :unprocessable_entity
  56. end
  57. end
  58. # DELETE /api/v1/legal/third_parties/:id
  59. def destroy
  60. authorize @third_party
  61. if @third_party.contracts.any?
  62. render json: { error: "No se puede eliminar un tercero con contratos asociados" }, status: :unprocessable_entity
  63. elsif @third_party.destroy
  64. render json: { message: "Tercero eliminado correctamente" }
  65. else
  66. render json: { errors: @third_party.errors.full_messages }, status: :unprocessable_entity
  67. end
  68. end
  69. # POST /api/v1/legal/third_parties/:id/activate
  70. def activate
  71. authorize @third_party, :update?
  72. @third_party.activate!
  73. render json: { data: third_party_json(@third_party), message: "Tercero activado" }
  74. end
  75. # POST /api/v1/legal/third_parties/:id/deactivate
  76. def deactivate
  77. authorize @third_party, :update?
  78. @third_party.deactivate!
  79. render json: { data: third_party_json(@third_party), message: "Tercero desactivado" }
  80. end
  81. # POST /api/v1/legal/third_parties/:id/block
  82. def block
  83. authorize @third_party, :update?
  84. reason = params[:reason]
  85. @third_party.block!(reason: reason)
  86. render json: { data: third_party_json(@third_party), message: "Tercero bloqueado" }
  87. end
  88. private
  89. def set_third_party
  90. @third_party = ::Legal::ThirdParty.find_by(uuid: params[:id])
  91. render json: { error: "Tercero no encontrado" }, status: :not_found unless @third_party
  92. end
  93. def third_party_params
  94. params.require(:third_party).permit(
  95. :third_party_type, :person_type, :status,
  96. :identification_type, :identification_number, :verification_digit,
  97. :business_name, :trade_name, :first_name, :last_name,
  98. :email, :phone, :mobile, :website,
  99. :address, :city, :state, :postal_code, :country,
  100. :legal_rep_name, :legal_rep_id_type, :legal_rep_id_number, :legal_rep_id_city,
  101. :legal_rep_email, :legal_rep_phone,
  102. :bank_name, :bank_account_type, :bank_account_number,
  103. :industry, :notes, :tax_regime,
  104. tags: [], tax_responsibilities: []
  105. )
  106. end
  107. def third_party_json(third_party, detailed: false)
  108. data = {
  109. id: third_party.uuid,
  110. code: third_party.code,
  111. third_party_type: third_party.third_party_type,
  112. type_label: third_party.type_label,
  113. person_type: third_party.person_type,
  114. status: third_party.status,
  115. status_label: third_party.status_label,
  116. display_name: third_party.display_name,
  117. identification_type: third_party.identification_type,
  118. identification_number: third_party.identification_number,
  119. full_identification: third_party.full_identification,
  120. email: third_party.email,
  121. phone: third_party.phone,
  122. city: third_party.city,
  123. country: third_party.country,
  124. contracts_count: third_party.contracts.count,
  125. created_at: third_party.created_at,
  126. updated_at: third_party.updated_at
  127. }
  128. if detailed
  129. data.merge!(
  130. verification_digit: third_party.verification_digit,
  131. business_name: third_party.business_name,
  132. trade_name: third_party.trade_name,
  133. first_name: third_party.first_name,
  134. last_name: third_party.last_name,
  135. mobile: third_party.mobile,
  136. website: third_party.website,
  137. address: third_party.address,
  138. state: third_party.state,
  139. postal_code: third_party.postal_code,
  140. full_address: third_party.full_address,
  141. legal_rep_name: third_party.legal_rep_name,
  142. legal_rep_id_type: third_party.legal_rep_id_type,
  143. legal_rep_id_number: third_party.legal_rep_id_number,
  144. legal_rep_id_city: third_party.legal_rep_id_city,
  145. legal_rep_email: third_party.legal_rep_email,
  146. legal_rep_phone: third_party.legal_rep_phone,
  147. bank_name: third_party.bank_name,
  148. bank_account_type: third_party.bank_account_type,
  149. bank_account_number: third_party.bank_account_number,
  150. industry: third_party.industry,
  151. tags: third_party.tags,
  152. notes: third_party.notes,
  153. tax_regime: third_party.tax_regime,
  154. tax_responsibilities: third_party.tax_responsibilities,
  155. created_by: third_party.created_by ? {
  156. id: third_party.created_by.uuid,
  157. name: third_party.created_by.full_name
  158. } : nil
  159. )
  160. end
  161. data
  162. end
  163. end
  164. end
  165. end
  166. end

app/controllers/api/v1/legal/third_party_types_controller.rb

0.0% lines covered

105 relevant lines. 0 lines covered and 105 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. module Legal
  5. class ThirdPartyTypesController < BaseController
  6. before_action :set_third_party_type, only: [:show, :update, :destroy, :toggle_active]
  7. def index
  8. authorize ::Legal::ThirdPartyType
  9. types = current_organization.third_party_types.ordered
  10. # Filter by active status if provided
  11. types = types.active if params[:active] == "true"
  12. render json: {
  13. success: true,
  14. data: types.map { |t| serialize_type(t) }
  15. }
  16. end
  17. def show
  18. authorize @third_party_type
  19. render json: {
  20. success: true,
  21. data: serialize_type(@third_party_type)
  22. }
  23. end
  24. def create
  25. authorize ::Legal::ThirdPartyType
  26. type = current_organization.third_party_types.new(type_params)
  27. if type.save
  28. render json: {
  29. success: true,
  30. data: serialize_type(type)
  31. }, status: :created
  32. else
  33. render json: {
  34. success: false,
  35. errors: type.errors.full_messages
  36. }, status: :unprocessable_entity
  37. end
  38. end
  39. def update
  40. authorize @third_party_type
  41. if @third_party_type.update(type_params)
  42. render json: {
  43. success: true,
  44. data: serialize_type(@third_party_type)
  45. }
  46. else
  47. render json: {
  48. success: false,
  49. errors: @third_party_type.errors.full_messages
  50. }, status: :unprocessable_entity
  51. end
  52. end
  53. def destroy
  54. authorize @third_party_type
  55. unless @third_party_type.deletable?
  56. return render json: {
  57. success: false,
  58. error: @third_party_type.is_system ? "No se pueden eliminar tipos del sistema" : "Este tipo tiene terceros asociados"
  59. }, status: :unprocessable_entity
  60. end
  61. @third_party_type.destroy
  62. render json: { success: true }
  63. end
  64. def toggle_active
  65. authorize @third_party_type
  66. @third_party_type.toggle_active!
  67. render json: {
  68. success: true,
  69. data: serialize_type(@third_party_type)
  70. }
  71. end
  72. private
  73. def set_third_party_type
  74. @third_party_type = current_organization.third_party_types.find(params[:id])
  75. rescue Mongoid::Errors::DocumentNotFound
  76. render json: { success: false, error: "Tipo no encontrado" }, status: :not_found
  77. end
  78. def type_params
  79. params.require(:third_party_type).permit(:code, :name, :description, :color, :icon, :active, :position)
  80. end
  81. def serialize_type(type)
  82. {
  83. id: type.id.to_s,
  84. code: type.code,
  85. name: type.name,
  86. description: type.description,
  87. color: type.color,
  88. icon: type.icon,
  89. active: type.active,
  90. is_system: type.is_system,
  91. position: type.position,
  92. deletable: type.deletable?,
  93. third_parties_count: ::Legal::ThirdParty.where(
  94. organization_id: type.organization_id,
  95. third_party_type: type.code
  96. ).count,
  97. created_at: type.created_at,
  98. updated_at: type.updated_at
  99. }
  100. end
  101. def current_organization
  102. @current_organization ||= current_user.organization
  103. end
  104. end
  105. end
  106. end
  107. end

app/controllers/api/v1/search_controller.rb

0.0% lines covered

66 relevant lines. 0 lines covered and 66 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. # Global search endpoint
  5. class SearchController < BaseController
  6. # GET /api/v1/search?q=query&type=documents,folders&page=1&per_page=20
  7. def index
  8. return render json: { error: "Search query (q) is required" }, status: :bad_request if params[:q].blank?
  9. results = search_service.search(
  10. params[:q],
  11. types: search_types,
  12. filters: search_filters,
  13. page: params[:page]&.to_i || 1,
  14. per_page: params[:per_page]&.to_i || 20
  15. )
  16. render json: {
  17. data: format_results(results),
  18. meta: {
  19. query: params[:q],
  20. total: results.total_count,
  21. page: results.current_page,
  22. per_page: results.per_page,
  23. total_pages: results.total_pages
  24. }
  25. }
  26. end
  27. private
  28. def search_service
  29. @search_service ||= Search::SearchService.new(
  30. organization: current_organization,
  31. user: current_user
  32. )
  33. end
  34. def search_types
  35. return nil if params[:type].blank?
  36. params[:type].split(",").map(&:strip)
  37. end
  38. def search_filters
  39. filters = {}
  40. filters[:folder_id] = params[:folder_id] if params[:folder_id].present?
  41. filters[:status] = params[:status] if params[:status].present?
  42. filters[:created_after] = params[:created_after] if params[:created_after].present?
  43. filters[:created_before] = params[:created_before] if params[:created_before].present?
  44. filters
  45. end
  46. def format_results(results)
  47. results.items.map do |item|
  48. {
  49. id: item.uuid,
  50. type: item.class.name.demodulize.underscore,
  51. title: item.respond_to?(:title) ? item.title : item.name,
  52. snippet: results.snippet_for(item),
  53. score: results.score_for(item),
  54. created_at: item.created_at.iso8601,
  55. url: item_url(item)
  56. }
  57. end
  58. end
  59. def item_url(item)
  60. case item
  61. when Content::Document
  62. "/documents/#{item.uuid}"
  63. when Content::Folder
  64. "/folders/#{item.uuid}"
  65. end
  66. end
  67. end
  68. end
  69. end

app/controllers/api/v1/templates_controller.rb

0.0% lines covered

96 relevant lines. 0 lines covered and 96 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. class TemplatesController < BaseController
  5. # GET /api/v1/templates
  6. # Public endpoint for listing active templates (read-only)
  7. def index
  8. templates = Templates::Template
  9. .where(organization_id: current_user.organization_id)
  10. .active
  11. # Filter by main_category
  12. if params[:main_category].present?
  13. templates = templates.where(main_category: params[:main_category])
  14. end
  15. # Filter by category
  16. if params[:category].present?
  17. templates = templates.where(category: params[:category])
  18. end
  19. # Filter by module_type
  20. if params[:module_type].present?
  21. templates = templates.where(module_type: params[:module_type])
  22. end
  23. # Search by name
  24. if params[:q].present?
  25. templates = templates.where(name: /#{Regexp.escape(params[:q])}/i)
  26. end
  27. templates = templates.order(name: :asc)
  28. render json: {
  29. success: true,
  30. data: templates.map { |t| template_json(t) }
  31. }
  32. end
  33. # GET /api/v1/templates/:id
  34. def show
  35. template = Templates::Template.find_by(
  36. uuid: params[:id],
  37. organization_id: current_user.organization_id,
  38. status: "active"
  39. )
  40. if template
  41. render json: {
  42. success: true,
  43. data: template_json(template, detailed: true)
  44. }
  45. else
  46. render json: { error: "Template no encontrado" }, status: :not_found
  47. end
  48. end
  49. # GET /api/v1/templates/:id/third_party_requirements
  50. def third_party_requirements
  51. template = Templates::Template.find_by(
  52. uuid: params[:id],
  53. organization_id: current_user.organization_id,
  54. status: "active"
  55. )
  56. unless template
  57. render json: { error: "Template no encontrado" }, status: :not_found
  58. return
  59. end
  60. render json: {
  61. data: {
  62. template_id: template.uuid,
  63. template_name: template.name,
  64. default_third_party_type: template.default_third_party_type,
  65. suggested_person_type: template.suggested_person_type,
  66. required_fields: template.required_third_party_fields,
  67. uses_third_party: template.uses_third_party_variables?,
  68. variables: template.variables,
  69. variables_count: template.variables&.count || 0
  70. }
  71. }
  72. end
  73. private
  74. def template_json(template, detailed: false)
  75. json = {
  76. id: template.uuid,
  77. name: template.name,
  78. description: template.description,
  79. module_type: template.module_type,
  80. main_category: template.main_category,
  81. category: template.category,
  82. default_third_party_type: template.default_third_party_type,
  83. uses_third_party: template.uses_third_party_variables?,
  84. signatories_count: template.signatories.count,
  85. sequential_signing: template.sequential_signing != false,
  86. variables_count: template.variables&.count || 0
  87. }
  88. if detailed
  89. json[:variables] = template.variables
  90. json[:signatories] = template.signatories.by_position.map do |sig|
  91. {
  92. id: sig.uuid,
  93. label: sig.label,
  94. role: sig.role,
  95. signatory_type_code: sig.signatory_type_code,
  96. required: sig.required,
  97. position: sig.position
  98. }
  99. end
  100. end
  101. json
  102. end
  103. end
  104. end
  105. end

app/controllers/api/v1/users_controller.rb

0.0% lines covered

41 relevant lines. 0 lines covered and 41 lines missed.
    
  1. # frozen_string_literal: true
  2. module Api
  3. module V1
  4. class UsersController < BaseController
  5. before_action :set_user, only: [:show]
  6. def index
  7. authorize Identity::User
  8. users = policy_scope(Identity::User).enabled
  9. render json: {
  10. data: users.map { |user| user_response(user) }
  11. }, status: :ok
  12. end
  13. def show
  14. authorize @user
  15. render json: {
  16. data: user_response(@user)
  17. }, status: :ok
  18. end
  19. private
  20. def set_user
  21. @user = Identity::User.find(params[:id])
  22. end
  23. def user_response(user)
  24. {
  25. id: user.id.to_s,
  26. email: user.email,
  27. first_name: user.first_name,
  28. last_name: user.last_name,
  29. full_name: user.full_name,
  30. employee_id: user.employee_id,
  31. department: user.department,
  32. title: user.title,
  33. roles: user.role_names,
  34. active: user.active,
  35. organization_id: user.organization_id&.to_s,
  36. created_at: user.created_at.iso8601,
  37. updated_at: user.updated_at.iso8601
  38. }
  39. end
  40. end
  41. end
  42. end

app/controllers/application_controller.rb

0.0% lines covered

51 relevant lines. 0 lines covered and 51 lines missed.
    
  1. # frozen_string_literal: true
  2. class ApplicationController < ActionController::API
  3. include Pundit::Authorization
  4. before_action :set_request_context
  5. rescue_from Pundit::NotAuthorizedError, with: :handle_unauthorized
  6. rescue_from Mongoid::Errors::DocumentNotFound, with: :handle_not_found
  7. rescue_from ActionController::ParameterMissing, with: :handle_bad_request
  8. protected
  9. def set_request_context
  10. Current.request_id = request.request_id
  11. Current.ip_address = request.remote_ip
  12. Current.user_agent = request.user_agent
  13. end
  14. def render_json(data, status: :ok, meta: {})
  15. response = { data: data }
  16. response[:meta] = meta if meta.present?
  17. render json: response, status: status
  18. end
  19. def render_error(message, status: :unprocessable_entity, errors: [])
  20. render json: {
  21. error: message,
  22. errors: Array(errors)
  23. }, status: status
  24. end
  25. def render_errors(errors, status: :unprocessable_entity)
  26. render json: {
  27. errors: Array(errors)
  28. }, status: status
  29. end
  30. private
  31. def handle_unauthorized(exception)
  32. render_error(
  33. "You are not authorized to perform this action",
  34. status: :forbidden,
  35. errors: [exception.message]
  36. )
  37. end
  38. def handle_not_found(exception)
  39. render_error(
  40. "Resource not found",
  41. status: :not_found,
  42. errors: [exception.message]
  43. )
  44. end
  45. def handle_bad_request(exception)
  46. render_error(
  47. "Bad request",
  48. status: :bad_request,
  49. errors: [exception.message]
  50. )
  51. end
  52. end

app/controllers/frontend_controller.rb

0.0% lines covered

5 relevant lines. 0 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. class FrontendController < ActionController::Base
  3. def index
  4. render file: Rails.public_path.join("index.html"), layout: false
  5. end
  6. end

app/jobs/application_job.rb

0.0% lines covered

25 relevant lines. 0 lines covered and 25 lines missed.
    
  1. # frozen_string_literal: true
  2. class ApplicationJob < ActiveJob::Base
  3. # Retry configuration
  4. retry_on StandardError, wait: :polynomially_longer, attempts: 3
  5. # Discard jobs when document not found (Mongoid equivalent)
  6. discard_on Mongoid::Errors::DocumentNotFound
  7. # Queue priority
  8. queue_with_priority 10
  9. # Logging
  10. around_perform do |job, block|
  11. Rails.logger.tagged("Job:#{job.class.name}", "JID:#{job.job_id}") do
  12. start_time = Time.current
  13. Rails.logger.info("Started job with args: #{job.arguments.inspect}")
  14. block.call
  15. duration = Time.current - start_time
  16. Rails.logger.info("Completed job in #{duration.round(2)}s")
  17. end
  18. rescue StandardError => e
  19. Rails.logger.error("Job failed: #{e.message}")
  20. Rails.logger.error(e.backtrace&.first(10)&.join("\n"))
  21. raise
  22. end
  23. protected
  24. def log_info(message)
  25. Rails.logger.info("[#{self.class.name}] #{message}")
  26. end
  27. def log_error(message)
  28. Rails.logger.error("[#{self.class.name}] #{message}")
  29. end
  30. end

app/jobs/retention_notification_job.rb

0.0% lines covered

196 relevant lines. 0 lines covered and 196 lines missed.
    
  1. # frozen_string_literal: true
  2. # Handles retention-related notifications
  3. # Supports warnings, pending actions, and legal hold notifications
  4. #
  5. # rubocop:disable Metrics/ClassLength
  6. class RetentionNotificationJob < ApplicationJob
  7. queue_as :default
  8. # @param notification_type [String] Type of notification
  9. # @param schedule_id [String] ID of the retention schedule
  10. # @param options [Hash] Additional notification options
  11. def perform(notification_type, schedule_id, **options)
  12. schedule = Retention::RetentionSchedule.find(schedule_id)
  13. case notification_type
  14. when "warning"
  15. handle_warning_notification(schedule, options)
  16. when "pending_action"
  17. handle_pending_action_notification(schedule, options)
  18. when "archived"
  19. handle_archived_notification(schedule, options)
  20. when "expired"
  21. handle_expired_notification(schedule, options)
  22. when "legal_hold_placed"
  23. handle_legal_hold_placed_notification(schedule, options)
  24. when "legal_hold_released"
  25. handle_legal_hold_released_notification(schedule, options)
  26. else
  27. Rails.logger.warn "[RetentionNotification] Unknown notification type: #{notification_type}"
  28. end
  29. rescue Mongoid::Errors::DocumentNotFound
  30. Rails.logger.error "[RetentionNotification] Schedule not found: #{schedule_id}"
  31. end
  32. private
  33. def handle_warning_notification(schedule, options)
  34. days = options[:days_until_expiration]
  35. document = schedule.document
  36. # Notify document owner/creator
  37. notify_document_stakeholders(
  38. schedule,
  39. subject: "[Retention Warning] Document expiring soon: #{document.title}",
  40. body: build_warning_message(schedule, days)
  41. )
  42. # Notify records managers
  43. notify_records_managers(
  44. schedule.organization,
  45. subject: "[Retention Warning] Document requires attention",
  46. body: build_warning_message(schedule, days)
  47. )
  48. Rails.logger.info "[RetentionNotification] Warning sent for #{document.title}"
  49. end
  50. def handle_pending_action_notification(schedule, options)
  51. action = options[:action]
  52. days_overdue = options[:days_overdue]
  53. document = schedule.document
  54. notify_records_managers(
  55. schedule.organization,
  56. subject: "[Retention Action Required] Document ready for #{action}: #{document.title}",
  57. body: build_pending_action_message(schedule, action, days_overdue)
  58. )
  59. Rails.logger.info "[RetentionNotification] Pending action notification for #{document.title}"
  60. end
  61. def handle_archived_notification(schedule, _options)
  62. document = schedule.document
  63. notify_document_stakeholders(
  64. schedule,
  65. subject: "[Retention] Document archived: #{document.title}",
  66. body: build_archived_message(schedule)
  67. )
  68. Rails.logger.info "[RetentionNotification] Archive notification for #{document.title}"
  69. end
  70. def handle_expired_notification(schedule, _options)
  71. document = schedule.document
  72. notify_document_stakeholders(
  73. schedule,
  74. subject: "[Retention] Document expired: #{document.title}",
  75. body: build_expired_message(schedule)
  76. )
  77. Rails.logger.info "[RetentionNotification] Expiration notification for #{document.title}"
  78. end
  79. def handle_legal_hold_placed_notification(schedule, options)
  80. hold_name = options[:hold_name]
  81. document = schedule.document
  82. notify_document_stakeholders(
  83. schedule,
  84. subject: "[Legal Hold] Document placed on hold: #{document.title}",
  85. body: build_legal_hold_placed_message(schedule, hold_name)
  86. )
  87. notify_legal_team(
  88. schedule.organization,
  89. subject: "[Legal Hold] Document preservation active",
  90. body: build_legal_hold_placed_message(schedule, hold_name)
  91. )
  92. Rails.logger.info "[RetentionNotification] Legal hold placed for #{document.title}"
  93. end
  94. def handle_legal_hold_released_notification(schedule, options)
  95. hold_name = options[:hold_name]
  96. document = schedule.document
  97. notify_document_stakeholders(
  98. schedule,
  99. subject: "[Legal Hold Released] #{document.title}",
  100. body: build_legal_hold_released_message(schedule, hold_name)
  101. )
  102. Rails.logger.info "[RetentionNotification] Legal hold released for #{document.title}"
  103. end
  104. # Notification delivery methods
  105. def notify_document_stakeholders(schedule, subject:, body:)
  106. document = schedule.document
  107. # Notify creator
  108. deliver_notification(document.created_by, subject, body) if document.created_by
  109. # Notify last modifier if different
  110. return unless document.last_modified_by && document.last_modified_by != document.created_by
  111. deliver_notification(document.last_modified_by, subject, body)
  112. end
  113. def notify_records_managers(organization, subject:, body:)
  114. # Notify users with records_manager or admin role
  115. ["records_manager", "admin"].each do |role_name|
  116. # rubocop:disable Rails/FindEach
  117. organization.users.joins(:roles).where(identity_roles: { name: role_name }).each do |user|
  118. deliver_notification(user, subject, body)
  119. end
  120. # rubocop:enable Rails/FindEach
  121. rescue StandardError
  122. next
  123. end
  124. end
  125. def notify_legal_team(organization, subject:, body:)
  126. # rubocop:disable Rails/FindEach
  127. organization.users.joins(:roles).where(identity_roles: { name: "legal" }).each do |user|
  128. deliver_notification(user, subject, body)
  129. end
  130. # rubocop:enable Rails/FindEach
  131. rescue StandardError => e
  132. Rails.logger.warn "[RetentionNotification] Could not notify legal team: #{e.message}"
  133. end
  134. def deliver_notification(user, subject, body)
  135. return unless user
  136. Rails.logger.info "[RetentionNotification] Delivering to #{user.email}: #{subject}"
  137. Audit::AuditEvent.log(
  138. event_type: Audit::AuditEvent::TYPES[:system],
  139. action: "notification_sent",
  140. target: user,
  141. actor: nil,
  142. metadata: {
  143. subject: subject,
  144. body_preview: body.to_s[0..100]
  145. },
  146. tags: ["notification", "retention"]
  147. )
  148. end
  149. # Message builders
  150. def build_warning_message(schedule, days)
  151. <<~MESSAGE
  152. Document Retention Warning
  153. Document: #{schedule.document.title}
  154. Policy: #{schedule.policy&.name || "N/A"}
  155. Expiration Date: #{schedule.expiration_date&.strftime("%Y-%m-%d")}
  156. Days Remaining: #{days}
  157. Action Required: #{schedule.policy&.expiration_action&.titleize || "Review"}
  158. Please review this document before the retention period expires.
  159. MESSAGE
  160. end
  161. def build_pending_action_message(schedule, action, days_overdue)
  162. <<~MESSAGE
  163. Document Ready for Retention Action
  164. Document: #{schedule.document.title}
  165. Policy: #{schedule.policy&.name || "N/A"}
  166. Expiration Date: #{schedule.expiration_date&.strftime("%Y-%m-%d")}
  167. Days Overdue: #{days_overdue}
  168. Required Action: #{action&.titleize || "Review"}
  169. This document has exceeded its retention period and requires action.
  170. MESSAGE
  171. end
  172. def build_archived_message(schedule)
  173. <<~MESSAGE
  174. Document Archived
  175. Document: #{schedule.document.title}
  176. Archived Date: #{schedule.action_date&.strftime("%Y-%m-%d %H:%M")}
  177. Policy: #{schedule.policy&.name || "N/A"}
  178. This document has been archived per retention policy.
  179. The document remains accessible in read-only mode.
  180. MESSAGE
  181. end
  182. def build_expired_message(schedule)
  183. <<~MESSAGE
  184. Document Expired
  185. Document: #{schedule.document.title}
  186. Expiration Date: #{schedule.action_date&.strftime("%Y-%m-%d %H:%M")}
  187. Policy: #{schedule.policy&.name || "N/A"}
  188. This document has been marked as expired per retention policy.
  189. The document is preserved but flagged as expired.
  190. MESSAGE
  191. end
  192. def build_legal_hold_placed_message(schedule, hold_name)
  193. <<~MESSAGE
  194. Legal Hold Placed on Document
  195. Document: #{schedule.document.title}
  196. Hold Name: #{hold_name}
  197. Effective Date: #{Time.current.strftime("%Y-%m-%d %H:%M")}
  198. IMPORTANT: This document is now under legal hold.
  199. - No modifications are permitted
  200. - No archival or deletion actions will be performed
  201. - The document must be preserved in its current state
  202. Contact your legal department for questions.
  203. MESSAGE
  204. end
  205. def build_legal_hold_released_message(schedule, hold_name)
  206. <<~MESSAGE
  207. Legal Hold Released
  208. Document: #{schedule.document.title}
  209. Hold Name: #{hold_name}
  210. Release Date: #{Time.current.strftime("%Y-%m-%d %H:%M")}
  211. The legal hold on this document has been released.
  212. Normal retention processing will resume.
  213. MESSAGE
  214. end
  215. end
  216. # rubocop:enable Metrics/ClassLength

app/jobs/retention_processor_job.rb

0.0% lines covered

132 relevant lines. 0 lines covered and 132 lines missed.
    
  1. # frozen_string_literal: true
  2. # Processes retention schedules on a scheduled basis
  3. # - Sends warnings for approaching expirations
  4. # - Marks documents for pending action when expired
  5. # - Does NOT physically delete any documents
  6. #
  7. # This job should be run daily via cron or similar scheduler
  8. #
  9. class RetentionProcessorJob < ApplicationJob
  10. queue_as :low
  11. # @param organization_id [String] Optional - process only for specific organization
  12. def perform(organization_id = nil)
  13. if organization_id
  14. process_organization(Identity::Organization.find(organization_id))
  15. else
  16. process_all_organizations
  17. end
  18. end
  19. private
  20. def process_all_organizations
  21. Identity::Organization.each do |org|
  22. process_organization(org)
  23. rescue StandardError => e
  24. Rails.logger.error "[RetentionProcessor] Error processing org #{org.id}: #{e.message}"
  25. end
  26. # Also process schedules without organization (shouldn't happen, but safety)
  27. process_global_schedules
  28. end
  29. def process_organization(organization)
  30. Rails.logger.info "[RetentionProcessor] Processing organization: #{organization.name}"
  31. stats = {
  32. warnings_sent: 0,
  33. marked_pending: 0,
  34. skipped_held: 0
  35. }
  36. # Process warning notifications
  37. stats[:warnings_sent] = process_warnings(organization)
  38. # Process expired documents
  39. result = process_expirations(organization)
  40. stats[:marked_pending] = result[:marked]
  41. stats[:skipped_held] = result[:skipped]
  42. log_processing_complete(organization, stats)
  43. end
  44. def process_global_schedules
  45. Retention::RetentionSchedule
  46. .where(organization_id: nil)
  47. .needs_warning
  48. .each { |schedule| send_warning(schedule) }
  49. Retention::RetentionSchedule
  50. .where(organization_id: nil)
  51. .past_expiration
  52. .each { |schedule| mark_for_action(schedule) }
  53. end
  54. def process_warnings(organization)
  55. count = 0
  56. # rubocop:disable Rails/FindEach
  57. Retention::RetentionSchedule
  58. .needs_warning
  59. .where(organization_id: organization.id)
  60. .each do |schedule|
  61. # rubocop:enable Rails/FindEach
  62. next if schedule.under_legal_hold?
  63. send_warning(schedule)
  64. count += 1
  65. rescue StandardError => e
  66. Rails.logger.error "[RetentionProcessor] Warning error for schedule #{schedule.id}: #{e.message}"
  67. end
  68. count
  69. end
  70. def process_expirations(organization)
  71. marked = 0
  72. skipped = 0
  73. # rubocop:disable Rails/FindEach
  74. Retention::RetentionSchedule
  75. .past_expiration
  76. .where(organization_id: organization.id)
  77. .each do |schedule|
  78. # rubocop:enable Rails/FindEach
  79. if schedule.under_legal_hold?
  80. skipped += 1
  81. log_skipped_due_to_hold(schedule)
  82. next
  83. end
  84. mark_for_action(schedule)
  85. marked += 1
  86. rescue StandardError => e
  87. Rails.logger.error "[RetentionProcessor] Expiration error for schedule #{schedule.id}: #{e.message}"
  88. end
  89. { marked: marked, skipped: skipped }
  90. end
  91. def send_warning(schedule)
  92. schedule.mark_warning!
  93. # Queue notification
  94. RetentionNotificationJob.perform_later(
  95. "warning",
  96. schedule.id.to_s,
  97. days_until_expiration: schedule.days_until_expiration
  98. )
  99. Rails.logger.info(
  100. "[RetentionProcessor] Warning sent for document #{schedule.document_id} " \
  101. "(expires in #{schedule.days_until_expiration} days)"
  102. )
  103. end
  104. def mark_for_action(schedule)
  105. schedule.mark_pending!
  106. # Queue notification for pending action
  107. RetentionNotificationJob.perform_later(
  108. "pending_action",
  109. schedule.id.to_s,
  110. action: schedule.policy&.expiration_action,
  111. days_overdue: schedule.days_overdue
  112. )
  113. Rails.logger.info(
  114. "[RetentionProcessor] Marked pending action for document #{schedule.document_id} " \
  115. "(overdue by #{schedule.days_overdue} days)"
  116. )
  117. end
  118. def log_skipped_due_to_hold(schedule)
  119. Rails.logger.info(
  120. "[RetentionProcessor] Skipped document #{schedule.document_id} - under legal hold"
  121. )
  122. # Record in audit
  123. Audit::AuditEvent.log(
  124. event_type: Audit::AuditEvent::TYPES[:system],
  125. action: "retention_skipped_legal_hold",
  126. target: schedule.document,
  127. actor: nil,
  128. metadata: {
  129. schedule_id: schedule.id.to_s,
  130. expiration_date: schedule.expiration_date&.iso8601,
  131. active_holds: schedule.legal_holds.active.count
  132. },
  133. tags: ["retention", "legal_hold", "skipped"]
  134. )
  135. end
  136. def log_processing_complete(organization, stats)
  137. Rails.logger.info(
  138. "[RetentionProcessor] Completed for #{organization.name}: " \
  139. "#{stats[:warnings_sent]} warnings, #{stats[:marked_pending]} marked pending, " \
  140. "#{stats[:skipped_held]} skipped (held)"
  141. )
  142. Audit::AuditEvent.log(
  143. event_type: Audit::AuditEvent::TYPES[:system],
  144. action: "retention_processing_complete",
  145. target: organization,
  146. actor: nil,
  147. metadata: stats,
  148. tags: ["retention", "processing", "batch"]
  149. )
  150. end
  151. end

app/jobs/sla_check_job.rb

0.0% lines covered

37 relevant lines. 0 lines covered and 37 lines missed.
    
  1. # frozen_string_literal: true
  2. # Checks if a workflow task has breached its SLA
  3. # Scheduled to run at the task's due time
  4. #
  5. class SlaCheckJob < ApplicationJob
  6. queue_as :default
  7. # @param task_id [String] ID of the workflow task to check
  8. def perform(task_id)
  9. task = Workflow::WorkflowTask.find(task_id)
  10. # Skip if task is already completed or cancelled
  11. return if task.completed? || task.status == Workflow::WorkflowTask::STATUS_CANCELLED
  12. handle_sla_breach(task) if task.overdue?
  13. rescue Mongoid::Errors::DocumentNotFound
  14. Rails.logger.warn "[SlaCheckJob] Task not found: #{task_id}"
  15. end
  16. private
  17. # rubocop:disable Metrics/MethodLength
  18. def handle_sla_breach(task)
  19. # Update task status
  20. task.update!(status: Workflow::WorkflowTask::STATUS_OVERDUE)
  21. # Escalate the task
  22. task.escalate!(reason: "SLA deadline breached")
  23. # Send breach notification
  24. WorkflowNotificationJob.perform_later(
  25. "sla_breached",
  26. task.id.to_s
  27. )
  28. # Record audit event
  29. Audit::AuditEvent.log(
  30. event_type: Audit::AuditEvent::TYPES[:workflow],
  31. action: "sla_breached",
  32. target: task,
  33. actor: nil, # System action
  34. metadata: {
  35. workflow_instance_id: task.instance_id.to_s,
  36. state: task.state,
  37. due_at: task.due_at&.iso8601,
  38. assigned_role: task.assigned_role,
  39. assignee_id: task.assignee_id&.to_s
  40. },
  41. tags: ["workflow", "sla", "breached"]
  42. )
  43. Rails.logger.warn(
  44. "[SlaCheckJob] SLA breached for task #{task.id} " \
  45. "(state: #{task.state}, due: #{task.due_at})"
  46. )
  47. end
  48. # rubocop:enable Metrics/MethodLength
  49. end

app/jobs/sla_warning_job.rb

0.0% lines covered

20 relevant lines. 0 lines covered and 20 lines missed.
    
  1. # frozen_string_literal: true
  2. # Sends SLA warning notifications before a task becomes overdue
  3. # Typically scheduled at 75% and 50% of the SLA period
  4. #
  5. class SlaWarningJob < ApplicationJob
  6. queue_as :default
  7. # @param task_id [String] ID of the workflow task
  8. # @param percentage_remaining [Integer] Percentage of SLA time remaining (e.g., 75, 50, 25)
  9. def perform(task_id, percentage_remaining)
  10. task = Workflow::WorkflowTask.find(task_id)
  11. # Skip if task is already completed, cancelled, or overdue
  12. return if task.completed?
  13. return if task.status == Workflow::WorkflowTask::STATUS_CANCELLED
  14. return if task.overdue?
  15. # Send warning notification
  16. WorkflowNotificationJob.perform_later(
  17. "sla_warning",
  18. task.id.to_s,
  19. percentage_remaining: percentage_remaining
  20. )
  21. Rails.logger.info(
  22. "[SlaWarningJob] Warning sent for task #{task.id} " \
  23. "(#{percentage_remaining}% time remaining)"
  24. )
  25. rescue Mongoid::Errors::DocumentNotFound
  26. Rails.logger.warn "[SlaWarningJob] Task not found: #{task_id}"
  27. end
  28. end

app/jobs/workflow_notification_job.rb

0.0% lines covered

263 relevant lines. 0 lines covered and 263 lines missed.
    
  1. # frozen_string_literal: true
  2. # Handles workflow-related notifications
  3. # Supports various notification types: transitions, escalations, SLA warnings
  4. #
  5. # rubocop:disable Metrics/ClassLength
  6. class WorkflowNotificationJob < ApplicationJob
  7. queue_as :default
  8. # @param notification_type [String] Type of notification
  9. # @param resource_id [String] ID of the workflow instance or task
  10. # @param options [Hash] Additional notification options
  11. def perform(notification_type, resource_id, **options)
  12. case notification_type
  13. when "transition"
  14. handle_transition_notification(resource_id, options)
  15. when "task_created"
  16. handle_task_created_notification(resource_id, options)
  17. when "task_escalated"
  18. handle_task_escalated_notification(resource_id, options)
  19. when "cancelled"
  20. handle_cancellation_notification(resource_id, options)
  21. when "sla_warning"
  22. handle_sla_warning_notification(resource_id, options)
  23. when "sla_breached"
  24. handle_sla_breached_notification(resource_id, options)
  25. else
  26. Rails.logger.warn "Unknown workflow notification type: #{notification_type}"
  27. end
  28. end
  29. private
  30. # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
  31. def handle_transition_notification(instance_id, options)
  32. instance = Workflow::WorkflowInstance.find(instance_id)
  33. from_state = options[:from_state]
  34. to_state = options[:to_state]
  35. actor = Identity::User.find(options[:actor_id])
  36. # Notify stakeholders about the state change
  37. notify_stakeholders(
  38. instance,
  39. subject: "Workflow transitioned: #{from_state} → #{to_state}",
  40. body: build_transition_message(instance, from_state, to_state, actor)
  41. )
  42. # If entering a new state with assigned role, notify that role
  43. if (step = instance.definition.step_for(to_state)) && step["assigned_role"] && step["assigned_role"]
  44. notify_role(
  45. instance.organization,
  46. step["assigned_role"],
  47. subject: "New task: #{instance.definition.name} - #{to_state}",
  48. body: build_new_task_message(instance, to_state)
  49. )
  50. end
  51. Rails.logger.info(
  52. "[WorkflowNotification] Transition: #{instance.definition.name} " \
  53. "#{from_state} → #{to_state} by #{actor.email}"
  54. )
  55. rescue Mongoid::Errors::DocumentNotFound => e
  56. Rails.logger.error "[WorkflowNotification] Resource not found: #{e.message}"
  57. end
  58. # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
  59. def handle_task_created_notification(task_id, options)
  60. task = Workflow::WorkflowTask.find(task_id)
  61. assigned_role = options[:assigned_role]
  62. state = options[:state]
  63. return unless assigned_role
  64. notify_role(
  65. task.organization,
  66. assigned_role,
  67. subject: "New workflow task available: #{state}",
  68. body: build_task_available_message(task)
  69. )
  70. Rails.logger.info(
  71. "[WorkflowNotification] Task created: #{task.id} " \
  72. "assigned to role: #{assigned_role}"
  73. )
  74. rescue Mongoid::Errors::DocumentNotFound => e
  75. Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
  76. end
  77. def handle_task_escalated_notification(task_id, options)
  78. task = Workflow::WorkflowTask.find(task_id)
  79. escalation_level = options[:escalation_level]
  80. reason = options[:reason]
  81. # Notify managers/admins about escalation
  82. notify_managers(
  83. task.organization,
  84. subject: "[ESCALATION Level #{escalation_level}] Workflow task requires attention",
  85. body: build_escalation_message(task, escalation_level, reason)
  86. )
  87. Rails.logger.info(
  88. "[WorkflowNotification] Task escalated: #{task.id} " \
  89. "level: #{escalation_level}"
  90. )
  91. rescue Mongoid::Errors::DocumentNotFound => e
  92. Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
  93. end
  94. def handle_cancellation_notification(instance_id, options)
  95. instance = Workflow::WorkflowInstance.find(instance_id)
  96. actor = Identity::User.find(options[:actor_id])
  97. reason = options[:reason]
  98. notify_stakeholders(
  99. instance,
  100. subject: "Workflow cancelled: #{instance.definition.name}",
  101. body: build_cancellation_message(instance, actor, reason)
  102. )
  103. Rails.logger.info(
  104. "[WorkflowNotification] Workflow cancelled: #{instance.id} " \
  105. "by #{actor.email}, reason: #{reason}"
  106. )
  107. rescue Mongoid::Errors::DocumentNotFound => e
  108. Rails.logger.error "[WorkflowNotification] Resource not found: #{e.message}"
  109. end
  110. # rubocop:disable Metrics/MethodLength
  111. def handle_sla_warning_notification(task_id, options)
  112. task = Workflow::WorkflowTask.find(task_id)
  113. percentage_remaining = options[:percentage_remaining]
  114. return if task.completed? || task.status == Workflow::WorkflowTask::STATUS_CANCELLED
  115. # Notify assignee if claimed, otherwise notify role
  116. if task.assignee
  117. notify_user(
  118. task.assignee,
  119. subject: "[SLA Warning] Task due soon: #{task.state}",
  120. body: build_sla_warning_message(task, percentage_remaining)
  121. )
  122. else
  123. notify_role(
  124. task.organization,
  125. task.assigned_role,
  126. subject: "[SLA Warning] Unclaimed task due soon: #{task.state}",
  127. body: build_sla_warning_message(task, percentage_remaining)
  128. )
  129. end
  130. Rails.logger.info(
  131. "[WorkflowNotification] SLA warning: #{task.id} " \
  132. "#{percentage_remaining}% time remaining"
  133. )
  134. rescue Mongoid::Errors::DocumentNotFound => e
  135. Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
  136. end
  137. # rubocop:enable Metrics/MethodLength
  138. def handle_sla_breached_notification(task_id, _options)
  139. task = Workflow::WorkflowTask.find(task_id)
  140. return if task.completed? || task.status == Workflow::WorkflowTask::STATUS_CANCELLED
  141. # Notify managers about SLA breach
  142. notify_managers(
  143. task.organization,
  144. subject: "[SLA BREACH] Task overdue: #{task.state}",
  145. body: build_sla_breach_message(task)
  146. )
  147. # Also notify assignee if claimed
  148. if task.assignee
  149. notify_user(
  150. task.assignee,
  151. subject: "[SLA BREACH] Your task is overdue: #{task.state}",
  152. body: build_sla_breach_message(task)
  153. )
  154. end
  155. Rails.logger.warn(
  156. "[WorkflowNotification] SLA breached: #{task.id}"
  157. )
  158. rescue Mongoid::Errors::DocumentNotFound => e
  159. Rails.logger.error "[WorkflowNotification] Task not found: #{e.message}"
  160. end
  161. # Notification delivery methods
  162. # These would integrate with your notification system (email, in-app, etc.)
  163. def notify_stakeholders(instance, subject:, body:)
  164. # Stakeholders: initiator + anyone in state history
  165. stakeholder_ids = [instance.initiated_by_id.to_s]
  166. instance.state_history.each do |entry|
  167. stakeholder_ids << entry["actor_id"] if entry["actor_id"]
  168. end
  169. stakeholder_ids.uniq.each do |user_id|
  170. user = Identity::User.find(user_id)
  171. deliver_notification(user, subject, body)
  172. rescue Mongoid::Errors::DocumentNotFound
  173. next
  174. end
  175. end
  176. def notify_role(organization, role_name, subject:, body:)
  177. return if role_name.blank?
  178. users_with_role = organization.users.joins(:roles).where(
  179. identity_roles: { name: role_name }
  180. )
  181. users_with_role.each do |user|
  182. deliver_notification(user, subject, body)
  183. end
  184. end
  185. def notify_managers(organization, subject:, body:)
  186. # Notify admin and manager roles
  187. ["admin", "manager"].each do |role_name|
  188. notify_role(organization, role_name, subject: subject, body: body)
  189. end
  190. end
  191. def notify_user(user, subject:, body:)
  192. return unless user
  193. deliver_notification(user, subject, body)
  194. end
  195. def deliver_notification(user, subject, body)
  196. # Placeholder for actual notification delivery
  197. # Could send email, create in-app notification, push notification, etc.
  198. Rails.logger.info(
  199. "[WorkflowNotification] Delivering to #{user.email}: #{subject}"
  200. )
  201. # Example: Create audit record of notification
  202. Audit::AuditEvent.log(
  203. event_type: Audit::AuditEvent::TYPES[:system],
  204. action: "notification_sent",
  205. target: user,
  206. actor: nil, # System notification
  207. metadata: {
  208. subject: subject,
  209. body_preview: body.to_s[0..100]
  210. },
  211. tags: ["notification", "workflow"]
  212. )
  213. end
  214. # Message builders
  215. def build_transition_message(instance, from_state, to_state, actor)
  216. <<~MESSAGE
  217. Workflow: #{instance.definition.name}
  218. Document: #{instance.document&.title || "N/A"}
  219. State changed from "#{from_state}" to "#{to_state}"
  220. Changed by: #{actor.full_name} (#{actor.email})
  221. Time: #{Time.current.strftime("%Y-%m-%d %H:%M %Z")}
  222. MESSAGE
  223. end
  224. def build_new_task_message(instance, state)
  225. step = instance.definition.step_for(state)
  226. sla_hours = instance.definition.sla_hours_for(state)
  227. <<~MESSAGE
  228. A new task is available for you to work on.
  229. Workflow: #{instance.definition.name}
  230. Document: #{instance.document&.title || "N/A"}
  231. Current State: #{state}
  232. Description: #{step["description"] || "N/A"}
  233. SLA: #{sla_hours ? "#{sla_hours} hours" : "No deadline"}
  234. MESSAGE
  235. end
  236. def build_task_available_message(task)
  237. <<~MESSAGE
  238. A workflow task is available for your role.
  239. State: #{task.state}
  240. Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z") || "No deadline"}
  241. Priority: #{task.priority}
  242. Please claim this task to begin working on it.
  243. MESSAGE
  244. end
  245. def build_escalation_message(task, level, reason)
  246. <<~MESSAGE
  247. A workflow task has been escalated and requires management attention.
  248. Escalation Level: #{level}
  249. Reason: #{reason || "Automatic escalation due to SLA"}
  250. Task Details:
  251. State: #{task.state}
  252. Assigned Role: #{task.assigned_role}
  253. Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z") || "No deadline"}
  254. Assignee: #{task.assignee&.full_name || "Unclaimed"}
  255. Time Remaining: #{task.time_remaining_text}
  256. MESSAGE
  257. end
  258. def build_cancellation_message(instance, actor, reason)
  259. <<~MESSAGE
  260. A workflow has been cancelled.
  261. Workflow: #{instance.definition.name}
  262. Document: #{instance.document&.title || "N/A"}
  263. Cancelled by: #{actor.full_name} (#{actor.email})
  264. Reason: #{reason || "No reason provided"}
  265. Time: #{Time.current.strftime("%Y-%m-%d %H:%M %Z")}
  266. Previous state: #{instance.current_state}
  267. MESSAGE
  268. end
  269. def build_sla_warning_message(task, percentage_remaining)
  270. <<~MESSAGE
  271. A workflow task is approaching its deadline.
  272. State: #{task.state}
  273. Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z")}
  274. Time Remaining: #{task.time_remaining_text} (#{percentage_remaining}%)
  275. Please complete this task before the deadline to avoid escalation.
  276. MESSAGE
  277. end
  278. def build_sla_breach_message(task)
  279. <<~MESSAGE
  280. [URGENT] A workflow task has exceeded its SLA deadline.
  281. State: #{task.state}
  282. Was Due: #{task.due_at&.strftime("%Y-%m-%d %H:%M %Z")}
  283. Overdue By: #{((Time.current - task.due_at) / 1.hour).round(1)} hours
  284. Assigned Role: #{task.assigned_role}
  285. Assignee: #{task.assignee&.full_name || "Unclaimed"}
  286. Immediate action is required.
  287. MESSAGE
  288. end
  289. end
  290. # rubocop:enable Metrics/ClassLength

app/mailers/application_mailer.rb

0.0% lines covered

4 relevant lines. 0 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. class ApplicationMailer < ActionMailer::Base
  3. default from: "from@example.com"
  4. layout "mailer"
  5. end

app/models/audit/audit_event.rb

78.13% lines covered

128 relevant lines. 100 lines covered and 28 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Audit
  3. # rubocop:disable Metrics/ClassLength
  4. 1 class AuditEvent
  5. 1 include Mongoid::Document
  6. 1 include Mongoid::Timestamps::Created
  7. # Store in separate collection for performance
  8. 1 store_in collection: "audit_events"
  9. # Event identification
  10. 1 field :uuid, type: String
  11. 1 field :event_type, type: String
  12. 1 field :action, type: String
  13. # Actor information (who performed the action)
  14. 1 field :actor_id, type: BSON::ObjectId
  15. 1 field :actor_type, type: String
  16. 1 field :actor_email, type: String
  17. 1 field :actor_name, type: String
  18. # Target information (what was affected)
  19. 1 field :target_id, type: BSON::ObjectId
  20. 1 field :target_type, type: String
  21. 1 field :target_uuid, type: String
  22. # Organization context
  23. 1 field :organization_id, type: BSON::ObjectId
  24. # Change details
  25. 1 field :change_data, type: Hash, default: {}
  26. 1 field :previous_values, type: Hash, default: {}
  27. 1 field :new_values, type: Hash, default: {}
  28. # Request context
  29. 1 field :request_id, type: String
  30. 1 field :ip_address, type: String
  31. 1 field :user_agent, type: String
  32. 1 field :session_id, type: String
  33. # Additional metadata
  34. 1 field :metadata, type: Hash, default: {}
  35. 1 field :tags, type: Array, default: []
  36. # Indexes for efficient querying
  37. 1 index({ uuid: 1 }, { unique: true })
  38. 1 index({ event_type: 1 })
  39. 1 index({ action: 1 })
  40. 1 index({ actor_id: 1 })
  41. 1 index({ target_id: 1 })
  42. 1 index({ target_type: 1 })
  43. 1 index({ organization_id: 1 })
  44. 1 index({ created_at: -1 })
  45. 1 index({ tags: 1 })
  46. 1 index({ target_type: 1, target_id: 1 })
  47. 1 index({ actor_id: 1, created_at: -1 })
  48. 1 index({ organization_id: 1, created_at: -1 })
  49. # Validations
  50. 1 validates :uuid, presence: true, uniqueness: true
  51. 1 validates :event_type, presence: true
  52. 1 validates :action, presence: true
  53. # Callbacks
  54. 1 before_validation :generate_uuid, on: :create
  55. 1 before_create :capture_request_context
  56. 1 after_create :ensure_immutable
  57. # Scopes
  58. 1 scope :by_event_type, ->(type) { where(event_type: type) }
  59. 1 scope :by_action, ->(action) { where(action: action) }
  60. 1 scope :by_actor, ->(actor_id) { where(actor_id: actor_id) }
  61. 1 scope :by_target, ->(target_type, target_id) { where(target_type: target_type, target_id: target_id) }
  62. 1 scope :by_organization, ->(org_id) { where(organization_id: org_id) }
  63. 1 scope :recent, -> { order(created_at: :desc) }
  64. 1 scope :since, ->(time) { where(:created_at.gte => time) }
  65. 1 scope :until, ->(time) { where(:created_at.lte => time) }
  66. 1 scope :tagged, ->(tag) { where(tags: tag) }
  67. # Event types
  68. 1 TYPES = {
  69. identity: "identity",
  70. content: "content",
  71. workflow: "workflow",
  72. system: "system",
  73. security: "security",
  74. record: "record", # Records management / retention
  75. hr: "hr" # Human resources / Intranet
  76. }.freeze
  77. # Common actions
  78. 1 ACTIONS = {
  79. create: "create",
  80. read: "read",
  81. update: "update",
  82. delete: "delete",
  83. restore: "restore",
  84. login: "login",
  85. logout: "logout",
  86. permission_change: "permission_change",
  87. export: "export",
  88. import: "import"
  89. }.freeze
  90. # Document-specific actions
  91. 1 DOCUMENT_ACTIONS = {
  92. document_created: "document_created",
  93. document_updated: "document_updated",
  94. document_deleted: "document_deleted",
  95. document_restored: "document_restored",
  96. document_locked: "document_locked",
  97. document_unlocked: "document_unlocked",
  98. document_moved: "document_moved",
  99. document_status_changed: "document_status_changed",
  100. version_created: "version_created",
  101. version_downloaded: "version_downloaded",
  102. version_viewed: "version_viewed"
  103. }.freeze
  104. # Folder-specific actions
  105. 1 FOLDER_ACTIONS = {
  106. folder_created: "folder_created",
  107. folder_updated: "folder_updated",
  108. folder_deleted: "folder_deleted",
  109. folder_moved: "folder_moved"
  110. }.freeze
  111. # All valid actions combined
  112. 1 ALL_ACTIONS = ACTIONS.merge(DOCUMENT_ACTIONS).merge(FOLDER_ACTIONS).freeze
  113. 1 class << self
  114. 1 def log(event_type:, action:, target: nil, actor: nil, change_data: {}, metadata: {}, tags: [])
  115. 6 create!(
  116. event_type: event_type,
  117. action: action,
  118. actor_id: actor&.id,
  119. actor_type: actor&.class&.name,
  120. actor_email: actor.try(:email),
  121. actor_name: actor.try(:full_name) || actor.try(:name),
  122. target_id: target&.id,
  123. target_type: target&.class&.name,
  124. target_uuid: target.try(:uuid),
  125. organization_id: extract_organization_id(actor, target),
  126. change_data: change_data,
  127. metadata: metadata,
  128. tags: Array(tags)
  129. )
  130. end
  131. 1 def log_model_change(record, action, change_data = {})
  132. 31 actor = Current.user
  133. 31 create!(
  134. event_type: event_type_for_model(record),
  135. action: action,
  136. actor_id: actor&.id,
  137. actor_type: actor&.class&.name,
  138. actor_email: actor.try(:email),
  139. actor_name: actor.try(:full_name) || actor.try(:name),
  140. target_id: record.id,
  141. target_type: record.class.name,
  142. target_uuid: record.try(:uuid),
  143. organization_id: extract_organization_id(actor, record),
  144. change_data: change_data,
  145. 495 previous_values: change_data.transform_values { |v| v.is_a?(Array) ? v.first : nil },
  146. 495 new_values: change_data.transform_values { |v| v.is_a?(Array) ? v.last : v }
  147. )
  148. end
  149. # Specialized document audit logging
  150. 1 def log_document_action(action:, document:, actor: nil, change_data: {}, metadata: {})
  151. actor ||= Current.user
  152. log(
  153. event_type: TYPES[:content],
  154. action: action,
  155. target: document,
  156. actor: actor,
  157. change_data: change_data,
  158. metadata: metadata.merge(
  159. document_title: document.try(:title),
  160. document_status: document.try(:status)
  161. ),
  162. tags: ["document", action]
  163. )
  164. end
  165. # Specialized version audit logging
  166. 1 def log_version_action(action:, version:, actor: nil, metadata: {})
  167. actor ||= Current.user
  168. log(
  169. event_type: TYPES[:content],
  170. action: action,
  171. target: version,
  172. actor: actor,
  173. metadata: metadata.merge(
  174. document_id: version.document_id&.to_s,
  175. version_number: version.version_number,
  176. file_name: version.file_name,
  177. content_type: version.content_type,
  178. file_size: version.file_size
  179. ),
  180. tags: ["version", action]
  181. )
  182. end
  183. # Specialized folder audit logging
  184. 1 def log_folder_action(action:, folder:, actor: nil, change_data: {}, metadata: {})
  185. actor ||= Current.user
  186. log(
  187. event_type: TYPES[:content],
  188. action: action,
  189. target: folder,
  190. actor: actor,
  191. change_data: change_data,
  192. metadata: metadata.merge(
  193. folder_name: folder.try(:name),
  194. folder_path: folder.try(:path)
  195. ),
  196. tags: ["folder", action]
  197. )
  198. end
  199. # Query methods for audit trail
  200. 1 def for_document(document)
  201. where(target_type: "Content::Document", target_id: document.id)
  202. .or(where("metadata.document_id" => document.id.to_s))
  203. .recent
  204. end
  205. 1 def for_version(version)
  206. where(target_type: "Content::DocumentVersion", target_id: version.id).recent
  207. end
  208. 1 def for_folder(folder)
  209. where(target_type: "Content::Folder", target_id: folder.id).recent
  210. end
  211. 1 def for_user(user)
  212. where(actor_id: user.id).recent
  213. end
  214. 1 private
  215. 1 def extract_organization_id(actor, target)
  216. 37 actor.try(:organization_id) || target.try(:organization_id) || Current.organization&.id
  217. end
  218. 1 def event_type_for_model(record)
  219. 31 class_name = record.class.name
  220. 31 case class_name
  221. when /\AIdentity::/
  222. 31 TYPES[:identity]
  223. when /\AContent::/
  224. TYPES[:content]
  225. when /\AWorkflow::/
  226. TYPES[:workflow]
  227. when /\AHr::/
  228. TYPES[:hr]
  229. when /\ARetention::/
  230. TYPES[:record]
  231. else
  232. # Audit::* and all other models default to system
  233. TYPES[:system]
  234. end
  235. end
  236. end
  237. # ============================================
  238. # IMMUTABILITY ENFORCEMENT (Append-Only Log)
  239. # ============================================
  240. # Instance-level immutability
  241. 1 def save(*)
  242. return super if new_record?
  243. raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
  244. end
  245. 1 def save!(*)
  246. return super if new_record?
  247. raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
  248. end
  249. 1 def update(*)
  250. raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
  251. end
  252. 1 def update!(*)
  253. raise ImmutableRecordError, "AuditEvent records cannot be modified after creation"
  254. end
  255. 1 def delete
  256. raise ImmutableRecordError, "AuditEvent records cannot be deleted"
  257. end
  258. 1 def destroy
  259. raise ImmutableRecordError, "AuditEvent records cannot be deleted"
  260. end
  261. 1 def destroy!
  262. raise ImmutableRecordError, "AuditEvent records cannot be deleted"
  263. end
  264. 1 def remove
  265. raise ImmutableRecordError, "AuditEvent records cannot be deleted"
  266. end
  267. # Class-level protection against mass operations
  268. # These are defined on the class itself, not on Mongoid::Criteria
  269. 1 def self.delete_all(*)
  270. raise ImmutableRecordError, "AuditEvent records cannot be deleted in bulk"
  271. end
  272. 1 def self.destroy_all(*)
  273. raise ImmutableRecordError, "AuditEvent records cannot be deleted in bulk"
  274. end
  275. 1 def self.update_all(*)
  276. raise ImmutableRecordError, "AuditEvent records cannot be updated in bulk"
  277. end
  278. 1 private
  279. 1 def generate_uuid
  280. 37 self.uuid ||= SecureRandom.uuid
  281. end
  282. 1 def capture_request_context
  283. 37 self.request_id ||= Current.request_id
  284. 37 self.ip_address ||= Current.ip_address
  285. 37 self.user_agent ||= Current.user_agent
  286. end
  287. 1 def ensure_immutable
  288. 37 readonly!
  289. end
  290. 1 class ImmutableRecordError < StandardError; end
  291. end
  292. # rubocop:enable Metrics/ClassLength
  293. end

app/models/concerns/audit_trackable.rb

62.86% lines covered

35 relevant lines. 22 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module AuditTrackable
  3. 1 extend ActiveSupport::Concern
  4. 1 included do
  5. 2 after_create :audit_create
  6. 2 after_update :audit_update
  7. 2 after_destroy :audit_destroy
  8. 2 class_attribute :audit_skip_fields, default: ["updated_at", "created_at", "search_text"]
  9. 2 class_attribute :audit_enabled, default: true
  10. end
  11. 1 module ClassMethods
  12. 1 def skip_audit_for(*fields)
  13. self.audit_skip_fields = audit_skip_fields + fields.map(&:to_s)
  14. end
  15. 1 def disable_audit
  16. self.audit_enabled = false
  17. end
  18. 1 def without_audit
  19. original_value = audit_enabled
  20. self.audit_enabled = false
  21. yield
  22. ensure
  23. self.audit_enabled = original_value
  24. end
  25. end
  26. 1 private
  27. 1 def audit_create
  28. 31 return unless audit_enabled?
  29. 31 Audit::AuditEvent.log_model_change(self, "create", auditable_attributes)
  30. end
  31. 1 def audit_update
  32. return unless audit_enabled?
  33. return if auditable_changes.empty?
  34. Audit::AuditEvent.log_model_change(self, "update", auditable_changes)
  35. end
  36. 1 def audit_destroy
  37. return unless audit_enabled?
  38. Audit::AuditEvent.log_model_change(self, "delete", auditable_attributes)
  39. end
  40. 1 def audit_enabled?
  41. self.class.audit_enabled
  42. end
  43. 1 def auditable_changes
  44. changes.except(*self.class.audit_skip_fields)
  45. end
  46. 1 def auditable_attributes
  47. 31 attributes.except(*self.class.audit_skip_fields)
  48. end
  49. end

app/models/concerns/soft_deletable.rb

57.58% lines covered

33 relevant lines. 19 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module SoftDeletable
  3. 1 extend ActiveSupport::Concern
  4. 1 included do
  5. 2 field :deleted_at, type: Time
  6. 2 field :deleted_by_id, type: BSON::ObjectId
  7. 2 index({ deleted_at: 1 })
  8. 60 scope :active, -> { where(deleted_at: nil) }
  9. 2 scope :deleted, -> { where(:deleted_at.ne => nil) }
  10. 2 scope :with_deleted, -> { unscoped }
  11. 101 default_scope -> { active }
  12. end
  13. 1 def soft_delete(user = nil)
  14. return false if deleted?
  15. update(
  16. deleted_at: Time.current,
  17. deleted_by_id: (user || Current.user)&.id
  18. )
  19. end
  20. 1 def soft_delete!(user = nil)
  21. soft_delete(user) || raise(Mongoid::Errors::DocumentNotFound.new(self.class, id))
  22. end
  23. 1 def restore
  24. return false unless deleted?
  25. update(
  26. deleted_at: nil,
  27. deleted_by_id: nil
  28. )
  29. end
  30. 1 def restore!
  31. restore || raise(Mongoid::Errors::DocumentNotFound.new(self.class, id))
  32. end
  33. 1 def deleted?
  34. deleted_at.present?
  35. end
  36. 1 def deleted_by
  37. return nil unless deleted_by_id
  38. @deleted_by ||= Identity::User.find(deleted_by_id)
  39. rescue Mongoid::Errors::DocumentNotFound
  40. nil
  41. end
  42. 1 module ClassMethods
  43. 1 def soft_delete_all(user = nil)
  44. update_all(
  45. deleted_at: Time.current,
  46. deleted_by_id: (user || Current.user)&.id
  47. )
  48. end
  49. 1 def restore_all
  50. unscoped.where(:deleted_at.ne => nil).update_all(
  51. deleted_at: nil,
  52. deleted_by_id: nil
  53. )
  54. end
  55. end
  56. end

app/models/concerns/uuid_identifiable.rb

86.67% lines covered

15 relevant lines. 13 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module UuidIdentifiable
  3. 1 extend ActiveSupport::Concern
  4. 1 included do
  5. 4 field :uuid, type: String
  6. 4 index({ uuid: 1 }, { unique: true })
  7. 4 before_create :generate_uuid
  8. 4 validates :uuid, uniqueness: true, allow_nil: true
  9. end
  10. 1 private
  11. 1 def generate_uuid
  12. 51 self.uuid ||= SecureRandom.uuid
  13. end
  14. 1 module ClassMethods
  15. 1 def find_by_uuid(uuid)
  16. where(uuid: uuid).first
  17. end
  18. 1 def find_by_uuid!(uuid)
  19. find_by_uuid(uuid) || raise(Mongoid::Errors::DocumentNotFound.new(self, { uuid: uuid }))
  20. end
  21. end
  22. end

app/models/content/document.rb

0.0% lines covered

280 relevant lines. 0 lines covered and 280 lines missed.
    
  1. # frozen_string_literal: true
  2. module Content
  3. # rubocop:disable Metrics/ClassLength
  4. class Document
  5. include Mongoid::Document
  6. include Mongoid::Timestamps
  7. include UuidIdentifiable
  8. include SoftDeletable
  9. include AuditTrackable
  10. store_in collection: "content_documents"
  11. # Status constants
  12. STATUS_DRAFT = "draft"
  13. STATUS_PENDING_REVIEW = "pending_review"
  14. STATUS_PUBLISHED = "published"
  15. STATUS_ARCHIVED = "archived"
  16. STATUSES = [STATUS_DRAFT, STATUS_PENDING_REVIEW, STATUS_PUBLISHED, STATUS_ARCHIVED].freeze
  17. # Fields
  18. field :title, type: String
  19. field :description, type: String
  20. field :status, type: String, default: STATUS_DRAFT
  21. field :document_type, type: String
  22. field :tags, type: Array, default: []
  23. field :metadata, type: Hash, default: {}
  24. # Versioning fields
  25. field :current_version_number, type: Integer, default: 0
  26. field :version_count, type: Integer, default: 0
  27. # Locking for concurrency control
  28. field :lock_version, type: Integer, default: 0
  29. field :locked_by_id, type: BSON::ObjectId
  30. field :locked_at, type: Time
  31. # Retention status
  32. field :retention_status, type: String # nil, "archived", "expired"
  33. field :last_modified_by_id, type: BSON::ObjectId
  34. # Indexes
  35. index({ uuid: 1 }, { unique: true })
  36. index({ title: 1 })
  37. index({ status: 1 })
  38. index({ document_type: 1 })
  39. index({ tags: 1 })
  40. index({ folder_id: 1 })
  41. index({ organization_id: 1 })
  42. index({ created_by_id: 1 })
  43. index({ current_version_id: 1 })
  44. index({ locked_by_id: 1 })
  45. index({ created_at: -1 })
  46. # Compound indexes for search optimization
  47. index({ organization_id: 1, status: 1, created_at: -1 })
  48. index({ organization_id: 1, folder_id: 1, status: 1 })
  49. index({ organization_id: 1, tags: 1 })
  50. index({ retention_status: 1 })
  51. # Associations
  52. belongs_to :folder, class_name: "Content::Folder", optional: true, inverse_of: :documents
  53. belongs_to :organization, class_name: "Identity::Organization", optional: true
  54. belongs_to :created_by, class_name: "Identity::User", optional: true
  55. belongs_to :last_modified_by, class_name: "Identity::User", optional: true
  56. has_one :retention_schedule, class_name: "Retention::RetentionSchedule", inverse_of: :document
  57. belongs_to :current_version, class_name: "Content::DocumentVersion", optional: true
  58. has_many :versions, class_name: "Content::DocumentVersion", inverse_of: :document, order: :version_number.desc
  59. # Validations
  60. validates :title, presence: true, length: { minimum: 1, maximum: 255 }
  61. validates :status, presence: true, inclusion: { in: STATUSES }
  62. validate :folder_belongs_to_same_organization
  63. validate :legal_hold_prevents_modification, on: :update
  64. # Audit callbacks - EVERY action must be audited
  65. after_create :audit_document_created
  66. after_update :audit_document_updated
  67. before_destroy :prevent_hard_delete
  68. # Scopes
  69. scope :drafts, -> { where(status: STATUS_DRAFT) }
  70. scope :pending_review, -> { where(status: STATUS_PENDING_REVIEW) }
  71. scope :published, -> { where(status: STATUS_PUBLISHED) }
  72. scope :archived, -> { where(status: STATUS_ARCHIVED) }
  73. scope :not_archived, -> { where(:status.ne => STATUS_ARCHIVED) }
  74. scope :by_status, ->(status) { where(status: status) }
  75. scope :by_type, ->(type) { where(document_type: type) }
  76. scope :by_folder, ->(folder_id) { where(folder_id: folder_id) }
  77. scope :by_organization, ->(org_id) { where(organization_id: org_id) }
  78. scope :tagged_with, ->(tag) { where(tags: tag) }
  79. scope :locked, -> { where(:locked_by_id.ne => nil) }
  80. scope :unlocked, -> { where(locked_by_id: nil) }
  81. scope :recent, ->(limit = 10) { order(created_at: :desc).limit(limit) }
  82. scope :retention_archived, -> { where(retention_status: "archived") }
  83. scope :retention_expired, -> { where(retention_status: "expired") }
  84. # Check if document is under legal hold
  85. def under_legal_hold?
  86. retention_schedule&.under_legal_hold? || false
  87. end
  88. # Check if modification is allowed (respects legal hold)
  89. def modification_allowed?
  90. !under_legal_hold?
  91. end
  92. # Create new version
  93. def create_version!(attributes = {})
  94. check_lock!
  95. check_legal_hold!
  96. version_attrs = attributes.merge(
  97. document: self,
  98. created_by: attributes[:created_by] || Current.user
  99. )
  100. version = Content::DocumentVersion.create!(version_attrs)
  101. # Update document with new current version - use ID for MongoDB serialization
  102. update_with_lock!(
  103. current_version_id: version.id,
  104. current_version_number: version.version_number,
  105. version_count: versions.count
  106. )
  107. version
  108. end
  109. # Optimistic locking update
  110. def update_with_lock!(attrs)
  111. current_lock = lock_version
  112. result = self.class.where(
  113. _id: id,
  114. lock_version: current_lock
  115. ).find_one_and_update(
  116. { "$set" => attrs.merge(lock_version: current_lock + 1, updated_at: Time.current) },
  117. return_document: :after
  118. )
  119. if result.nil?
  120. reload
  121. raise ConcurrencyError, "Document was modified by another process. Please reload and try again."
  122. end
  123. # Reload to get updated attributes
  124. reload
  125. self
  126. end
  127. # Locking methods
  128. def lock!(user)
  129. return false if locked? && locked_by_id != user.id
  130. update_with_lock!(
  131. locked_by_id: user.id,
  132. locked_at: Time.current
  133. )
  134. audit_lock_event("document_locked", user)
  135. true
  136. rescue ConcurrencyError
  137. false
  138. end
  139. def unlock!(user = nil)
  140. return false unless locked?
  141. return false if user && locked_by_id != user.id && !user.admin?
  142. update_with_lock!(
  143. locked_by_id: nil,
  144. locked_at: nil
  145. )
  146. audit_lock_event("document_unlocked", user)
  147. true
  148. rescue ConcurrencyError
  149. false
  150. end
  151. def locked?
  152. locked_by_id.present?
  153. end
  154. def locked_by?(user)
  155. locked_by_id == user.id
  156. end
  157. def locked_by
  158. return nil unless locked_by_id
  159. @locked_by ||= Identity::User.find(locked_by_id)
  160. rescue Mongoid::Errors::DocumentNotFound
  161. nil
  162. end
  163. # Version access
  164. def latest_version
  165. current_version || versions.first
  166. end
  167. def version(number)
  168. versions.find_by(version_number: number)
  169. end
  170. def version_history
  171. versions.order(version_number: :asc)
  172. end
  173. # Status transitions
  174. def publish!
  175. update!(status: STATUS_PUBLISHED)
  176. end
  177. def archive!
  178. update!(status: STATUS_ARCHIVED)
  179. end
  180. def submit_for_review!
  181. update!(status: STATUS_PENDING_REVIEW)
  182. end
  183. # rubocop:disable Metrics/PerceivedComplexity
  184. def move_to_folder!(new_folder)
  185. return false if new_folder && new_folder.organization_id != organization_id
  186. old_folder_id = folder_id
  187. result = update!(folder: new_folder)
  188. if result
  189. Audit::AuditEvent.log_document_action(
  190. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_moved],
  191. document: self,
  192. change_data: { folder_id: [old_folder_id&.to_s, new_folder&.id&.to_s] },
  193. metadata: {
  194. old_folder_path: old_folder_id ? Content::Folder.find(old_folder_id)&.path : nil,
  195. new_folder_path: new_folder&.path
  196. }
  197. )
  198. end
  199. result
  200. end
  201. # rubocop:enable Metrics/PerceivedComplexity
  202. # Soft delete with audit trail
  203. def soft_delete_with_audit!(user = nil)
  204. user ||= Current.user
  205. result = soft_delete(user)
  206. if result
  207. Audit::AuditEvent.log_document_action(
  208. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_deleted],
  209. document: self,
  210. actor: user,
  211. change_data: { deleted_at: [nil, deleted_at&.iso8601] },
  212. metadata: { soft_delete: true }
  213. )
  214. end
  215. result
  216. end
  217. # Restore with audit trail
  218. def restore_with_audit!(user = nil)
  219. user ||= Current.user
  220. old_deleted_at = deleted_at
  221. result = restore
  222. if result
  223. Audit::AuditEvent.log_document_action(
  224. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_restored],
  225. document: self,
  226. actor: user,
  227. change_data: { deleted_at: [old_deleted_at&.iso8601, nil] },
  228. metadata: { restored: true }
  229. )
  230. end
  231. result
  232. end
  233. # Get complete audit trail for this document
  234. def audit_trail
  235. Audit::AuditEvent.for_document(self)
  236. end
  237. private
  238. def check_lock!
  239. return unless locked?
  240. return if locked_by_id == Current.user&.id
  241. raise DocumentLockedError, "Document is locked by another user"
  242. end
  243. def check_legal_hold!
  244. return unless under_legal_hold?
  245. raise LegalHoldError, "Document is under legal hold and cannot be modified"
  246. end
  247. def legal_hold_prevents_modification
  248. return unless under_legal_hold?
  249. # Only allow retention_status changes when under hold
  250. return if [["retention_status"], ["retention_status", "updated_at"]].include?(changes.keys)
  251. return if changes.keys == ["updated_at"]
  252. return if changes.empty?
  253. errors.add(:base, "Document is under legal hold and cannot be modified")
  254. end
  255. def folder_belongs_to_same_organization
  256. return unless folder && organization_id
  257. return if folder.organization_id == organization_id
  258. errors.add(:folder, "must belong to the same organization")
  259. end
  260. def prevent_hard_delete
  261. raise HardDeleteNotAllowedError, "Documents cannot be hard deleted. Use soft_delete_with_audit! instead."
  262. end
  263. def audit_document_created
  264. Audit::AuditEvent.log_document_action(
  265. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:document_created],
  266. document: self,
  267. change_data: attributes.except("_id", "updated_at", "created_at"),
  268. metadata: { initial_status: status }
  269. )
  270. end
  271. def audit_document_updated
  272. # Use previous_changes since changes is cleared after save
  273. relevant_changes = previous_changes.except("updated_at", "lock_version", "created_at")
  274. return if relevant_changes.empty?
  275. # Detect status change for special logging
  276. action = if relevant_changes.key?("status")
  277. Audit::AuditEvent::DOCUMENT_ACTIONS[:document_status_changed]
  278. else
  279. Audit::AuditEvent::DOCUMENT_ACTIONS[:document_updated]
  280. end
  281. Audit::AuditEvent.log_document_action(
  282. action: action,
  283. document: self,
  284. change_data: relevant_changes,
  285. metadata: build_update_metadata(relevant_changes)
  286. )
  287. end
  288. def audit_lock_event(action, user)
  289. Audit::AuditEvent.log_document_action(
  290. action: action,
  291. document: self,
  292. actor: user,
  293. change_data: { locked_by_id: locked_by_id&.to_s, locked_at: locked_at&.iso8601 },
  294. metadata: { lock_action: action }
  295. )
  296. end
  297. def build_update_metadata(changes)
  298. metadata = {}
  299. metadata[:status_transition] = changes["status"] if changes.key?("status")
  300. metadata[:title_changed] = true if changes.key?("title")
  301. metadata[:folder_changed] = true if changes.key?("folder_id")
  302. metadata[:version_updated] = true if changes.key?("current_version_id")
  303. metadata
  304. end
  305. class ConcurrencyError < StandardError; end
  306. class DocumentLockedError < StandardError; end
  307. class HardDeleteNotAllowedError < StandardError; end
  308. class LegalHoldError < StandardError; end
  309. end
  310. # rubocop:enable Metrics/ClassLength
  311. end

app/models/content/document_version.rb

0.0% lines covered

128 relevant lines. 0 lines covered and 128 lines missed.
    
  1. # frozen_string_literal: true
  2. module Content
  3. class DocumentVersion
  4. include Mongoid::Document
  5. include Mongoid::Timestamps::Created
  6. include UuidIdentifiable
  7. store_in collection: "document_versions"
  8. # Fields - all immutable after creation
  9. field :version_number, type: Integer
  10. field :file_name, type: String
  11. field :file_size, type: Integer
  12. field :content_type, type: String
  13. field :checksum, type: String
  14. field :storage_key, type: String
  15. field :content, type: String # For text content (can be replaced with file storage)
  16. # Version metadata
  17. field :change_summary, type: String
  18. field :metadata, type: Hash, default: {}
  19. # Indexes
  20. index({ uuid: 1 }, { unique: true })
  21. index({ document_id: 1, version_number: 1 }, { unique: true })
  22. index({ document_id: 1, created_at: -1 })
  23. index({ checksum: 1 })
  24. index({ created_by_id: 1 })
  25. # Associations
  26. belongs_to :document, class_name: "Content::Document", inverse_of: :versions
  27. belongs_to :created_by, class_name: "Identity::User", optional: true
  28. # Validations
  29. validates :version_number, presence: true, numericality: { greater_than: 0 }
  30. validates :version_number, uniqueness: { scope: :document_id }
  31. validates :file_name, presence: true, length: { maximum: 255 }
  32. validates :content_type, presence: true
  33. validates :checksum, presence: true
  34. # Callbacks
  35. before_validation :set_version_number, on: :create
  36. before_validation :calculate_checksum, on: :create
  37. after_create :log_version_created
  38. # Immutability enforcement
  39. def save(*)
  40. return super if new_record?
  41. raise ImmutableRecordError, "DocumentVersion records cannot be modified after creation"
  42. end
  43. def update(*)
  44. raise ImmutableRecordError, "DocumentVersion records cannot be modified after creation"
  45. end
  46. def update!(*)
  47. raise ImmutableRecordError, "DocumentVersion records cannot be modified after creation"
  48. end
  49. def delete
  50. raise ImmutableRecordError, "DocumentVersion records cannot be deleted"
  51. end
  52. def destroy
  53. raise ImmutableRecordError, "DocumentVersion records cannot be deleted"
  54. end
  55. # Instance methods
  56. def previous_version
  57. return nil if version_number == 1
  58. document.versions.where(version_number: version_number - 1).first
  59. end
  60. def next_version
  61. document.versions.where(version_number: version_number + 1).first
  62. end
  63. def latest?
  64. document.current_version_id == id
  65. end
  66. def content_changed_from_previous?
  67. return true if version_number == 1
  68. prev = previous_version
  69. prev.nil? || prev.checksum != checksum
  70. end
  71. # Audit methods for tracking access (these don't modify the version, just log)
  72. # Log a download event for this version
  73. def log_download!(user = nil)
  74. user ||= Current.user
  75. Audit::AuditEvent.log_version_action(
  76. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_downloaded],
  77. version: self,
  78. actor: user,
  79. metadata: {
  80. download_timestamp: Time.current.iso8601,
  81. user_ip: Current.ip_address,
  82. user_agent: Current.user_agent
  83. }
  84. )
  85. end
  86. # Log a view event for this version
  87. def log_view!(user = nil)
  88. user ||= Current.user
  89. Audit::AuditEvent.log_version_action(
  90. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_viewed],
  91. version: self,
  92. actor: user,
  93. metadata: {
  94. view_timestamp: Time.current.iso8601
  95. }
  96. )
  97. end
  98. # Get download count from audit trail
  99. def download_count
  100. Audit::AuditEvent.where(
  101. target_type: "Content::DocumentVersion",
  102. target_id: id,
  103. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_downloaded]
  104. ).count
  105. end
  106. # Get view count from audit trail
  107. def view_count
  108. Audit::AuditEvent.where(
  109. target_type: "Content::DocumentVersion",
  110. target_id: id,
  111. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_viewed]
  112. ).count
  113. end
  114. # Get complete audit trail for this version
  115. def audit_trail
  116. Audit::AuditEvent.for_version(self)
  117. end
  118. private
  119. def set_version_number
  120. return if version_number.present?
  121. max_version = document&.versions&.max(:version_number) || 0
  122. self.version_number = max_version + 1
  123. end
  124. def calculate_checksum
  125. return if checksum.present?
  126. return if content.blank? && storage_key.blank?
  127. content_to_hash = content || storage_key
  128. self.checksum = Digest::SHA256.hexdigest(content_to_hash)
  129. end
  130. def log_version_created
  131. Audit::AuditEvent.log_version_action(
  132. action: Audit::AuditEvent::DOCUMENT_ACTIONS[:version_created],
  133. version: self,
  134. actor: created_by || Current.user,
  135. metadata: {
  136. change_summary: change_summary,
  137. content_changed: content_changed_from_previous?
  138. }
  139. )
  140. end
  141. class ImmutableRecordError < StandardError; end
  142. end
  143. end

app/models/content/folder.rb

0.0% lines covered

174 relevant lines. 0 lines covered and 174 lines missed.
    
  1. # frozen_string_literal: true
  2. module Content
  3. # rubocop:disable Metrics/ClassLength
  4. class Folder
  5. include Mongoid::Document
  6. include Mongoid::Timestamps
  7. include UuidIdentifiable
  8. include SoftDeletable
  9. include AuditTrackable
  10. store_in collection: "folders"
  11. # Fields
  12. field :name, type: String
  13. field :description, type: String
  14. field :path, type: String
  15. field :depth, type: Integer, default: 0
  16. field :metadata, type: Hash, default: {}
  17. # Indexes
  18. index({ uuid: 1 }, { unique: true })
  19. index({ name: 1 })
  20. index({ path: 1 }, { unique: true })
  21. index({ parent_id: 1 })
  22. index({ organization_id: 1 })
  23. index({ depth: 1 })
  24. index({ created_by_id: 1 })
  25. # Associations
  26. belongs_to :parent, class_name: "Content::Folder", optional: true, inverse_of: :children
  27. has_many :children, class_name: "Content::Folder", inverse_of: :parent, dependent: :restrict_with_error
  28. has_many :documents, class_name: "Content::Document", inverse_of: :folder, dependent: :restrict_with_error
  29. belongs_to :organization, class_name: "Identity::Organization", optional: true
  30. belongs_to :created_by, class_name: "Identity::User", optional: true
  31. # Validations
  32. validates :name, presence: true, length: { minimum: 1, maximum: 255 }
  33. validates :name, format: { with: %r{\A[^/\\]+\z}, message: "cannot contain slashes" }
  34. validates :path, presence: true, uniqueness: { scope: :organization_id }
  35. validate :parent_not_self
  36. validate :parent_depth_limit
  37. # Callbacks
  38. before_validation :build_path, on: :create
  39. before_validation :update_path, on: :update, if: :parent_id_changed?
  40. # Audit callbacks
  41. after_create :audit_folder_created
  42. after_update :audit_folder_updated
  43. before_destroy :prevent_hard_delete
  44. after_save :update_children_paths, if: :saved_change_to_path?
  45. # Constants
  46. MAX_DEPTH = 10
  47. # Scopes
  48. scope :root_folders, -> { where(parent_id: nil) }
  49. scope :by_organization, ->(org_id) { where(organization_id: org_id) }
  50. scope :by_parent, ->(parent_id) { where(parent_id: parent_id) }
  51. scope :alphabetical, -> { order(name: :asc) }
  52. def root?
  53. parent_id.nil?
  54. end
  55. def ancestors
  56. return [] if root?
  57. ancestors_list = []
  58. current = parent
  59. while current
  60. ancestors_list.unshift(current)
  61. current = current.parent
  62. end
  63. ancestors_list
  64. end
  65. def ancestor_ids
  66. ancestors.map(&:id)
  67. end
  68. def descendants
  69. all_descendants = []
  70. children.each do |child|
  71. all_descendants << child
  72. all_descendants.concat(child.descendants)
  73. end
  74. all_descendants
  75. end
  76. def descendant_ids
  77. descendants.map(&:id)
  78. end
  79. def full_path
  80. path
  81. end
  82. # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity, Naming/PredicateMethod
  83. def move_to(new_parent)
  84. return false if new_parent == self
  85. return false if new_parent && descendant_ids.include?(new_parent.id)
  86. old_parent_id = parent_id
  87. old_path = path
  88. self.parent = new_parent
  89. if save
  90. Audit::AuditEvent.log_folder_action(
  91. action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_moved],
  92. folder: self,
  93. change_data: {
  94. parent_id: [old_parent_id&.to_s, new_parent&.id&.to_s],
  95. path: [old_path, path]
  96. },
  97. metadata: {
  98. old_parent_path: old_parent_id ? Content::Folder.find(old_parent_id)&.path : nil,
  99. new_parent_path: new_parent&.path
  100. }
  101. )
  102. true
  103. else
  104. false
  105. end
  106. end
  107. # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity, Naming/PredicateMethod
  108. def document_count(include_descendants: false)
  109. count = documents.count
  110. if include_descendants
  111. children.each do |child|
  112. count += child.document_count(include_descendants: true)
  113. end
  114. end
  115. count
  116. end
  117. # Soft delete with audit trail
  118. def soft_delete_with_audit!(user = nil)
  119. user ||= Current.user
  120. result = soft_delete(user)
  121. if result
  122. Audit::AuditEvent.log_folder_action(
  123. action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_deleted],
  124. folder: self,
  125. actor: user,
  126. change_data: { deleted_at: [nil, deleted_at&.iso8601] },
  127. metadata: { soft_delete: true }
  128. )
  129. end
  130. result
  131. end
  132. # Get complete audit trail for this folder
  133. def audit_trail
  134. Audit::AuditEvent.for_folder(self)
  135. end
  136. private
  137. def build_path
  138. self.path = if parent
  139. "#{parent.path}/#{name}"
  140. else
  141. "/#{name}"
  142. end
  143. self.depth = parent ? parent.depth + 1 : 0
  144. end
  145. def update_path
  146. build_path
  147. end
  148. def update_children_paths
  149. children.each do |child|
  150. child.send(:build_path)
  151. child.save!
  152. end
  153. end
  154. def parent_not_self
  155. return unless parent_id.present? && parent_id == id
  156. errors.add(:parent_id, "cannot be self")
  157. end
  158. def parent_depth_limit
  159. # MAX_DEPTH is the maximum allowed depth value (0-based)
  160. # A folder at depth MAX_DEPTH-1 cannot have children
  161. return unless parent && parent.depth >= MAX_DEPTH - 1
  162. errors.add(:parent_id, "maximum folder depth (#{MAX_DEPTH}) exceeded")
  163. end
  164. def prevent_hard_delete
  165. raise HardDeleteNotAllowedError, "Folders cannot be hard deleted. Use soft_delete_with_audit! instead."
  166. end
  167. def audit_folder_created
  168. Audit::AuditEvent.log_folder_action(
  169. action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_created],
  170. folder: self,
  171. change_data: attributes.except("_id", "updated_at", "created_at"),
  172. metadata: { initial_depth: depth }
  173. )
  174. end
  175. def audit_folder_updated
  176. relevant_changes = previous_changes.except("updated_at", "created_at")
  177. return if relevant_changes.empty?
  178. Audit::AuditEvent.log_folder_action(
  179. action: Audit::AuditEvent::FOLDER_ACTIONS[:folder_updated],
  180. folder: self,
  181. change_data: relevant_changes,
  182. metadata: {
  183. name_changed: relevant_changes.key?("name"),
  184. path_changed: relevant_changes.key?("path")
  185. }
  186. )
  187. end
  188. class HardDeleteNotAllowedError < StandardError; end
  189. end
  190. # rubocop:enable Metrics/ClassLength
  191. end

app/models/current.rb

81.82% lines covered

11 relevant lines. 9 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 class Current < ActiveSupport::CurrentAttributes
  3. 1 attribute :user
  4. 1 attribute :organization
  5. 1 attribute :request_id
  6. 1 attribute :ip_address
  7. 1 attribute :user_agent
  8. 1 resets do
  9. 23 Time.zone = "UTC"
  10. end
  11. 1 def user=(value)
  12. super
  13. Time.zone = value&.time_zone || "UTC"
  14. end
  15. end

app/models/documents/folder.rb

0.0% lines covered

58 relevant lines. 0 lines covered and 58 lines missed.
    
  1. # frozen_string_literal: true
  2. module Documents
  3. class Folder
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. # Fields
  8. field :name, type: String
  9. field :description, type: String
  10. field :color, type: String, default: "#6366f1" # Primary color
  11. field :icon, type: String, default: "folder"
  12. field :is_system, type: Boolean, default: false
  13. field :documents_count, type: Integer, default: 0
  14. # Associations
  15. belongs_to :organization, class_name: "Identity::Organization"
  16. belongs_to :created_by, class_name: "Identity::User"
  17. belongs_to :parent, class_name: "Documents::Folder", optional: true
  18. has_many :subfolders, class_name: "Documents::Folder", inverse_of: :parent, dependent: :destroy
  19. has_many :folder_documents, class_name: "Documents::FolderDocument", dependent: :destroy
  20. # Validations
  21. validates :name, presence: true, length: { maximum: 100 }
  22. validates :name, uniqueness: { scope: [:organization_id, :parent_id] }
  23. validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/, message: "debe ser un color hexadecimal válido" }, allow_blank: true
  24. # Indexes
  25. index({ organization_id: 1, parent_id: 1, name: 1 }, { unique: true })
  26. index({ organization_id: 1, created_at: -1 })
  27. # Scopes
  28. scope :for_organization, ->(org) { where(organization_id: org.id) }
  29. scope :root_folders, -> { where(parent_id: nil) }
  30. scope :ordered, -> { order(name: :asc) }
  31. # Callbacks
  32. before_destroy :prevent_system_folder_deletion
  33. # Get full path of folder
  34. def full_path
  35. ancestors = []
  36. current = self
  37. while current
  38. ancestors.unshift(current.name)
  39. current = current.parent
  40. end
  41. ancestors.join(" / ")
  42. end
  43. # Get all ancestor folders
  44. def ancestors
  45. result = []
  46. current = parent
  47. while current
  48. result.unshift(current)
  49. current = current.parent
  50. end
  51. result
  52. end
  53. # Get documents in this folder
  54. def documents
  55. folder_documents.includes(:document).map(&:document).compact
  56. end
  57. # Update documents count
  58. def update_documents_count!
  59. update!(documents_count: folder_documents.count)
  60. end
  61. private
  62. def prevent_system_folder_deletion
  63. if is_system
  64. errors.add(:base, "No se puede eliminar una carpeta del sistema")
  65. throw(:abort)
  66. end
  67. end
  68. end
  69. end

app/models/documents/folder_document.rb

0.0% lines covered

21 relevant lines. 0 lines covered and 21 lines missed.
    
  1. # frozen_string_literal: true
  2. module Documents
  3. class FolderDocument
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. # Associations
  7. belongs_to :folder, class_name: "Documents::Folder"
  8. belongs_to :document, class_name: "Templates::GeneratedDocument"
  9. belongs_to :added_by, class_name: "Identity::User"
  10. # Validations
  11. validates :document_id, uniqueness: { scope: :folder_id, message: "ya está en esta carpeta" }
  12. # Indexes
  13. index({ folder_id: 1, document_id: 1 }, { unique: true })
  14. index({ document_id: 1 })
  15. # Callbacks
  16. after_create :increment_folder_count
  17. after_destroy :decrement_folder_count
  18. private
  19. def increment_folder_count
  20. folder.inc(documents_count: 1)
  21. end
  22. def decrement_folder_count
  23. folder.inc(documents_count: -1)
  24. end
  25. end
  26. end

app/models/health_check.rb

0.0% lines covered

24 relevant lines. 0 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. class HealthCheck
  3. include Mongoid::Document
  4. include Mongoid::Timestamps
  5. store_in collection: "health_checks"
  6. field :status, type: String, default: "ok"
  7. field :checked_at, type: Time
  8. validates :status, presence: true, inclusion: { in: ["ok", "degraded", "error"] }
  9. before_create :set_checked_at
  10. def self.ping
  11. create!(status: "ok")
  12. true
  13. rescue StandardError
  14. false
  15. end
  16. def self.mongodb_connected?
  17. Mongoid.default_client.command(ping: 1).ok?
  18. rescue StandardError
  19. false
  20. end
  21. private
  22. def set_checked_at
  23. self.checked_at = Time.current
  24. end
  25. end

app/models/hr/employee.rb

85.56% lines covered

180 relevant lines. 154 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Hr
  3. # Employee profile extending User with HR-specific data
  4. # Tracks vacation balance, supervisor hierarchy, and employment details
  5. #
  6. 1 class Employee
  7. 1 include Mongoid::Document
  8. 1 include Mongoid::Timestamps
  9. 1 include UuidIdentifiable
  10. 1 store_in collection: "hr_employees"
  11. # Employment status constants
  12. 1 STATUS_ACTIVE = "active"
  13. 1 STATUS_ON_LEAVE = "on_leave"
  14. 1 STATUS_TERMINATED = "terminated"
  15. 1 STATUS_SUSPENDED = "suspended"
  16. 1 STATUSES = [STATUS_ACTIVE, STATUS_ON_LEAVE, STATUS_TERMINATED, STATUS_SUSPENDED].freeze
  17. # Employment type constants
  18. 1 TYPE_FULL_TIME = "full_time"
  19. 1 TYPE_PART_TIME = "part_time"
  20. 1 TYPE_CONTRACTOR = "contractor"
  21. 1 TYPE_INTERN = "intern"
  22. 1 EMPLOYMENT_TYPES = [TYPE_FULL_TIME, TYPE_PART_TIME, TYPE_CONTRACTOR, TYPE_INTERN].freeze
  23. # Contract type constants
  24. 1 CONTRACT_INDEFINITE = "indefinite"
  25. 1 CONTRACT_FIXED_TERM = "fixed_term"
  26. 1 CONTRACT_WORK_OR_LABOR = "work_or_labor"
  27. 1 CONTRACT_APPRENTICE = "apprentice"
  28. 1 CONTRACT_TYPES = [CONTRACT_INDEFINITE, CONTRACT_FIXED_TERM, CONTRACT_WORK_OR_LABOR, CONTRACT_APPRENTICE].freeze
  29. # Fields
  30. 1 field :employee_number, type: String
  31. 1 field :employment_status, type: String, default: STATUS_ACTIVE
  32. 1 field :employment_type, type: String, default: TYPE_FULL_TIME
  33. 1 field :hire_date, type: Date
  34. 1 field :termination_date, type: Date
  35. 1 field :job_title, type: String
  36. 1 field :department, type: String
  37. 1 field :cost_center, type: String
  38. # Personal name fields (stored independently, also used before user account exists)
  39. 1 field :first_name, type: String
  40. 1 field :last_name, type: String
  41. # Contract fields
  42. 1 field :contract_type, type: String, default: CONTRACT_INDEFINITE
  43. 1 field :contract_template_id, type: String # UUID of the contract template
  44. 1 field :contract_start_date, type: Date
  45. 1 field :contract_end_date, type: Date # For fixed-term contracts
  46. 1 field :contract_duration_value, type: Integer # Duration value for fixed-term
  47. 1 field :contract_duration_unit, type: String, default: "months" # days, weeks, months, years
  48. 1 field :trial_period_days, type: Integer, default: 60
  49. # Duration unit constants
  50. 1 DURATION_DAYS = "days"
  51. 1 DURATION_WEEKS = "weeks"
  52. 1 DURATION_MONTHS = "months"
  53. 1 DURATION_YEARS = "years"
  54. 1 DURATION_UNITS = [DURATION_DAYS, DURATION_WEEKS, DURATION_MONTHS, DURATION_YEARS].freeze
  55. # Compensation fields
  56. 1 field :salary, type: BigDecimal # Monthly salary
  57. 1 field :food_allowance, type: BigDecimal, default: 0 # Auxilio de alimentacion
  58. 1 field :transport_allowance, type: BigDecimal, default: 0 # Auxilio de transporte
  59. 1 field :payment_frequency, type: String, default: "monthly" # weekly, biweekly, monthly
  60. 1 field :work_city, type: String # Ciudad donde labora
  61. # Personal identification
  62. 1 field :identification_type, type: String, default: "CC" # CC, CE, PA, etc.
  63. 1 field :identification_number, type: String # Cedula
  64. 1 field :place_of_birth, type: String
  65. 1 field :nationality, type: String, default: "Colombiana"
  66. 1 field :address, type: String
  67. 1 field :phone, type: String
  68. 1 field :personal_email, type: String # Email personal para crear cuenta de acceso
  69. 1 field :work_email, type: String # Email corporativo (se actualiza cuando el usuario lo cambia)
  70. # Vacation balance (mock for now - would integrate with payroll system)
  71. 1 field :vacation_balance_days, type: Float, default: 0.0
  72. 1 field :vacation_accrued_ytd, type: Float, default: 0.0
  73. 1 field :vacation_used_ytd, type: Float, default: 0.0
  74. 1 field :vacation_carry_over, type: Float, default: 0.0
  75. # Sick leave balance
  76. 1 field :sick_leave_balance_days, type: Float, default: 0.0
  77. 1 field :sick_leave_used_ytd, type: Float, default: 0.0
  78. # Personal data
  79. 1 field :date_of_birth, type: Date
  80. 1 field :emergency_contact_name, type: String
  81. 1 field :emergency_contact_phone, type: String
  82. # Indexes
  83. 1 index({ uuid: 1 }, { unique: true })
  84. 1 index({ employee_number: 1 }, { unique: true, sparse: true })
  85. 1 index({ user_id: 1 }, { unique: true, sparse: true })
  86. 1 index({ supervisor_id: 1 })
  87. 1 index({ organization_id: 1 })
  88. 1 index({ employment_status: 1 })
  89. 1 index({ department: 1 })
  90. 1 index({ organization_id: 1, employment_status: 1 })
  91. # Associations
  92. 1 belongs_to :user, class_name: "Identity::User", optional: true # Optional until account is created
  93. 1 belongs_to :supervisor, class_name: "Hr::Employee", optional: true
  94. 1 belongs_to :organization, class_name: "Identity::Organization"
  95. 1 has_many :subordinates, class_name: "Hr::Employee", inverse_of: :supervisor
  96. 1 has_many :vacation_requests, class_name: "Hr::VacationRequest", inverse_of: :employee
  97. 1 has_many :certification_requests, class_name: "Hr::EmploymentCertificationRequest", inverse_of: :employee
  98. # Validations
  99. 1 validates :user_id, uniqueness: true, allow_blank: true
  100. 1 validates :employment_status, inclusion: { in: STATUSES }
  101. 1 validates :employment_type, inclusion: { in: EMPLOYMENT_TYPES }
  102. 1 validates :contract_type, inclusion: { in: CONTRACT_TYPES }, allow_blank: true
  103. 1 validates :vacation_balance_days, numericality: { greater_than_or_equal_to: 0 }
  104. 1 validates :employee_number, uniqueness: true, allow_blank: true
  105. 1 validates :salary, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
  106. 1 validates :personal_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
  107. # Callbacks
  108. 1 after_save :sync_user_name, if: :should_sync_user_name?
  109. # Scopes
  110. 1 scope :active, -> { where(employment_status: STATUS_ACTIVE) }
  111. 1 scope :on_leave, -> { where(employment_status: STATUS_ON_LEAVE) }
  112. 1 scope :terminated, -> { where(employment_status: STATUS_TERMINATED) }
  113. 1 scope :by_department, ->(dept) { where(department: dept) }
  114. 1 scope :by_supervisor, ->(supervisor) { where(supervisor_id: supervisor.id) }
  115. 1 scope :full_time, -> { where(employment_type: TYPE_FULL_TIME) }
  116. # Delegate user attributes (when user exists)
  117. 1 delegate :email, to: :user, allow_nil: true
  118. 1 delegate :has_role?, :has_permission?, :admin?, to: :user
  119. # Name methods - use local fields first, fallback to user
  120. 1 def display_first_name
  121. 10 first_name.presence || user&.first_name
  122. end
  123. 1 def display_last_name
  124. 10 last_name.presence || user&.last_name
  125. end
  126. 1 def full_name
  127. 10 "#{display_first_name} #{display_last_name}".strip
  128. end
  129. # Check if employee is a supervisor
  130. 1 def supervisor?
  131. subordinates.active.exists?
  132. end
  133. # Check if employee is HR staff
  134. 1 def hr_staff?
  135. 2 return false unless user
  136. 2 user.has_role?("hr") || user.has_role?("hr_manager") || user.admin?
  137. end
  138. # Check if employee is HR manager
  139. 1 def hr_manager?
  140. 2 return false unless user
  141. 2 user.has_role?("hr_manager") || user.admin?
  142. end
  143. # Get associated contract template
  144. 1 def contract_template
  145. return nil unless contract_template_id.present?
  146. Templates::Template.find_by(uuid: contract_template_id)
  147. end
  148. # Check if this employee supervises another
  149. 1 def supervises?(other_employee)
  150. 2 return false unless other_employee
  151. 2 other_employee.supervisor_id == id
  152. end
  153. # Get all subordinates recursively (direct reports + their reports)
  154. 1 def all_subordinates
  155. direct = subordinates.to_a
  156. indirect = direct.flat_map(&:all_subordinates)
  157. direct + indirect
  158. end
  159. # Check if has sufficient vacation balance
  160. 1 def has_vacation_balance?(days) # rubocop:disable Naming/PredicatePrefix
  161. 10 available_vacation_days >= days
  162. end
  163. # Deduct vacation days (called when request is approved)
  164. 1 def deduct_vacation!(days)
  165. 4 raise InsufficientBalanceError, "Insufficient vacation balance" unless has_vacation_balance?(days)
  166. 3 self.vacation_balance_days -= days
  167. 3 self.vacation_used_ytd += days
  168. 3 save!
  169. end
  170. # Restore vacation days (called when approved request is cancelled)
  171. 1 def restore_vacation!(days)
  172. 2 self.vacation_balance_days += days
  173. 2 self.vacation_used_ytd -= days
  174. 2 save!
  175. end
  176. # Mock: Accrue vacation days (would be called by payroll integration)
  177. 1 def accrue_vacation!(days)
  178. 1 self.vacation_balance_days += days
  179. 1 self.vacation_accrued_ytd += days
  180. 1 save!
  181. end
  182. # Get pending vacation requests
  183. 1 def pending_vacation_requests
  184. vacation_requests.pending
  185. end
  186. # Get approved vacation days for a date range
  187. 1 def approved_vacation_days_in_range(start_date, end_date)
  188. vacation_requests
  189. .approved
  190. .overlapping(start_date, end_date)
  191. .sum(&:business_days)
  192. end
  193. # ============================================
  194. # Vacation Balance Calculations (Ley Colombiana)
  195. # 15 días hábiles por año trabajado
  196. # ============================================
  197. # Días acumulados según antigüedad
  198. 1 def accrued_vacation_days
  199. 10 return 0.0 unless hire_date
  200. 10 years_of_service = (Date.current - hire_date).to_f / 365.25
  201. 10 (years_of_service * 15).round(2)
  202. end
  203. # Días programados (aprobados pero aún no disfrutados)
  204. 1 def scheduled_vacation_days
  205. vacation_requests
  206. .approved
  207. .sum(:days_requested)
  208. end
  209. # Días ya disfrutados
  210. 1 def enjoyed_vacation_days
  211. vacation_requests
  212. .enjoyed
  213. .sum(:days_requested)
  214. end
  215. # Total de días usados (programados + disfrutados)
  216. 1 def total_used_vacation_days
  217. 10 vacation_requests
  218. .used
  219. .sum(:days_requested)
  220. end
  221. # Días disponibles totales (acumulados - usados)
  222. 1 def available_vacation_days
  223. 10 (accrued_vacation_days - total_used_vacation_days).round(2)
  224. end
  225. # Días disponibles sin programación (para nuevas solicitudes)
  226. 1 def available_for_request
  227. available_vacation_days
  228. end
  229. # Días disponibles reales (excluyendo los ya programados)
  230. 1 def truly_available_days
  231. (accrued_vacation_days - enjoyed_vacation_days).round(2)
  232. end
  233. # Resumen de vacaciones
  234. 1 def vacation_summary
  235. {
  236. accrued: accrued_vacation_days,
  237. scheduled: scheduled_vacation_days,
  238. enjoyed: enjoyed_vacation_days,
  239. total_used: total_used_vacation_days,
  240. available: available_vacation_days,
  241. truly_available: truly_available_days
  242. }
  243. end
  244. # Get supervisor chain (for escalation)
  245. 1 def supervisor_chain
  246. chain = []
  247. current = supervisor
  248. while current
  249. chain << current
  250. current = current.supervisor
  251. end
  252. chain
  253. end
  254. 1 private
  255. # Sync employee name to associated user account
  256. 1 def sync_user_name
  257. return unless user
  258. user.update(
  259. first_name: first_name,
  260. last_name: last_name
  261. )
  262. end
  263. # Only sync if name fields changed and user exists
  264. 1 def should_sync_user_name?
  265. 29 user_id.present? && (saved_change_to_first_name? || saved_change_to_last_name?)
  266. end
  267. 1 class << self
  268. # Find employee by user
  269. 1 def for_user(user)
  270. where(user_id: user.id).first
  271. end
  272. # Find or create employee for user
  273. 1 def find_or_create_for_user!(user, attributes = {})
  274. where(user_id: user.id).first || create!(
  275. attributes.merge(
  276. user: user,
  277. organization: user.organization
  278. )
  279. )
  280. end
  281. # Mock: Initialize vacation balances for new year
  282. 1 def reset_vacation_balances_for_year!(organization, default_days: 15)
  283. active.where(organization_id: organization.id).find_each do |employee|
  284. carry_over = [employee.vacation_balance_days, 5].min # Max 5 days carry over
  285. employee.update!(
  286. vacation_carry_over: carry_over,
  287. vacation_balance_days: default_days + carry_over,
  288. vacation_accrued_ytd: 0,
  289. vacation_used_ytd: 0
  290. )
  291. end
  292. end
  293. end
  294. 1 class InsufficientBalanceError < StandardError; end
  295. end
  296. end

app/models/hr/employment_certification_request.rb

0.0% lines covered

218 relevant lines. 0 lines covered and 218 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. # Employment certification request (constancia laboral)
  4. # Can be requested by employee, processed by HR
  5. #
  6. # rubocop:disable Metrics/ClassLength
  7. class EmploymentCertificationRequest
  8. include Mongoid::Document
  9. include Mongoid::Timestamps
  10. include UuidIdentifiable
  11. store_in collection: "hr_certification_requests"
  12. # Status constants
  13. STATUS_PENDING = "pending"
  14. STATUS_PROCESSING = "processing"
  15. STATUS_COMPLETED = "completed"
  16. STATUS_REJECTED = "rejected"
  17. STATUS_CANCELLED = "cancelled"
  18. STATUSES = [STATUS_PENDING, STATUS_PROCESSING, STATUS_COMPLETED, STATUS_REJECTED, STATUS_CANCELLED].freeze
  19. # Certification type constants
  20. TYPE_EMPLOYMENT = "employment" # Basic employment verification
  21. TYPE_SALARY = "salary" # With salary information
  22. TYPE_POSITION = "position" # Position/role details
  23. TYPE_FULL = "full" # Complete employment details
  24. TYPE_CUSTOM = "custom" # Custom content requested
  25. CERTIFICATION_TYPES = [TYPE_EMPLOYMENT, TYPE_SALARY, TYPE_POSITION, TYPE_FULL, TYPE_CUSTOM].freeze
  26. # Purpose constants (why they need the letter)
  27. PURPOSE_BANK = "bank" # Bank loan/credit
  28. PURPOSE_VISA = "visa" # Visa application
  29. PURPOSE_RENTAL = "rental" # Rental application
  30. PURPOSE_GOVERNMENT = "government" # Government procedures
  31. PURPOSE_LEGAL = "legal" # Legal proceedings
  32. PURPOSE_OTHER = "other"
  33. PURPOSES = [PURPOSE_BANK, PURPOSE_VISA, PURPOSE_RENTAL, PURPOSE_GOVERNMENT, PURPOSE_LEGAL, PURPOSE_OTHER].freeze
  34. # Fields
  35. field :request_number, type: String
  36. field :certification_type, type: String, default: TYPE_EMPLOYMENT
  37. field :purpose, type: String, default: PURPOSE_OTHER
  38. field :purpose_details, type: String
  39. field :addressee, type: String # "To whom it may concern" or specific
  40. field :language, type: String, default: "es" # es, en
  41. field :special_instructions, type: String
  42. field :status, type: String, default: STATUS_PENDING
  43. # Include specific data (for salary type)
  44. field :include_salary, type: Boolean, default: false
  45. field :include_start_date, type: Boolean, default: true
  46. field :include_position, type: Boolean, default: true
  47. field :include_department, type: Boolean, default: false
  48. # Processing fields
  49. field :submitted_at, type: Time
  50. field :processed_at, type: Time
  51. field :completed_at, type: Time
  52. field :rejection_reason, type: String
  53. field :processor_notes, type: String
  54. # Generated document reference
  55. field :document_uuid, type: String # Reference to generated PDF document
  56. field :pickup_date, type: Date
  57. field :delivery_method, type: String, default: "digital" # digital, physical, both
  58. # History tracking
  59. field :history, type: Array, default: []
  60. # Indexes
  61. index({ uuid: 1 }, { unique: true })
  62. index({ request_number: 1 }, { unique: true, sparse: true })
  63. index({ employee_id: 1 })
  64. index({ processed_by_id: 1 })
  65. index({ organization_id: 1 })
  66. index({ status: 1 })
  67. index({ organization_id: 1, status: 1 })
  68. index({ employee_id: 1, status: 1 })
  69. # Associations
  70. belongs_to :employee, class_name: "Hr::Employee"
  71. belongs_to :processed_by, class_name: "Hr::Employee", optional: true
  72. belongs_to :organization, class_name: "Identity::Organization"
  73. # Validations
  74. validates :certification_type, inclusion: { in: CERTIFICATION_TYPES }
  75. validates :purpose, inclusion: { in: PURPOSES }
  76. validates :status, inclusion: { in: STATUSES }
  77. validates :language, inclusion: { in: ["es", "en"] }
  78. validate :salary_permission_required, if: :include_salary?
  79. # Callbacks
  80. before_create :generate_request_number
  81. before_create :set_submitted_at
  82. after_create :log_request_created
  83. # Scopes
  84. scope :pending, -> { where(status: STATUS_PENDING) }
  85. scope :processing, -> { where(status: STATUS_PROCESSING) }
  86. scope :completed, -> { where(status: STATUS_COMPLETED) }
  87. scope :rejected, -> { where(status: STATUS_REJECTED) }
  88. scope :cancelled, -> { where(status: STATUS_CANCELLED) }
  89. scope :for_processing, -> { where(:status.in => [STATUS_PENDING, STATUS_PROCESSING]) }
  90. # State predicates
  91. def pending?
  92. status == STATUS_PENDING
  93. end
  94. def processing?
  95. status == STATUS_PROCESSING
  96. end
  97. def completed?
  98. status == STATUS_COMPLETED
  99. end
  100. def rejected?
  101. status == STATUS_REJECTED
  102. end
  103. def cancelled?
  104. status == STATUS_CANCELLED
  105. end
  106. # Start processing (by HR)
  107. def start_processing!(actor:)
  108. raise InvalidStateError, "Can only process pending requests" unless pending?
  109. raise AuthorizationError, "Only HR staff can process requests" unless actor.hr_staff?
  110. self.status = STATUS_PROCESSING
  111. self.processed_by = actor
  112. self.processed_at = Time.current
  113. record_history("processing_started", actor)
  114. save!
  115. log_audit_event("certification_processing_started", actor)
  116. self
  117. end
  118. # Complete request (by HR)
  119. # rubocop:disable Naming/PredicateMethod
  120. def complete!(actor:, document_uuid: nil, notes: nil)
  121. raise InvalidStateError, "Can only complete processing requests" unless processing?
  122. raise AuthorizationError, "Only HR staff can complete requests" unless actor.hr_staff?
  123. self.status = STATUS_COMPLETED
  124. self.completed_at = Time.current
  125. self.document_uuid = document_uuid
  126. self.processor_notes = notes
  127. record_history("completed", actor, notes)
  128. save!
  129. log_audit_event("certification_completed", actor, { document_uuid: document_uuid })
  130. true
  131. end
  132. # Reject request (by HR)
  133. def reject!(actor:, reason:)
  134. raise InvalidStateError, "Cannot reject completed requests" if completed?
  135. raise AuthorizationError, "Only HR staff can reject requests" unless actor.hr_staff?
  136. raise ValidationError, "Rejection reason is required" if reason.blank?
  137. self.status = STATUS_REJECTED
  138. self.rejection_reason = reason
  139. self.completed_at = Time.current
  140. record_history("rejected", actor, reason)
  141. save!
  142. log_audit_event("certification_rejected", actor, { reason: reason })
  143. true
  144. end
  145. # Cancel request (by employee)
  146. def cancel!(actor:, reason: nil)
  147. raise InvalidStateError, "Cannot cancel completed or rejected requests" if completed? || rejected?
  148. raise AuthorizationError, "Only the employee can cancel their request" unless can_cancel?(actor)
  149. self.status = STATUS_CANCELLED
  150. record_history("cancelled", actor, reason)
  151. save!
  152. log_audit_event("certification_cancelled", actor, { reason: reason })
  153. true
  154. end
  155. # Check if actor can view this request
  156. def can_view?(actor)
  157. return true if actor.hr_staff?
  158. return true if actor.id == employee.id
  159. return true if actor.supervises?(employee)
  160. false
  161. end
  162. # Check if actor can cancel this request
  163. def can_cancel?(actor)
  164. return false if completed? || rejected?
  165. return true if actor.hr_staff?
  166. actor.id == employee.id
  167. end
  168. # rubocop:enable Naming/PredicateMethod
  169. # Estimated processing time in days
  170. # rubocop:disable Lint/DuplicateBranch
  171. def estimated_days
  172. case certification_type
  173. when TYPE_EMPLOYMENT, TYPE_POSITION
  174. 1
  175. when TYPE_SALARY
  176. 2
  177. when TYPE_FULL, TYPE_CUSTOM
  178. 3
  179. else
  180. 1 # Default to shortest time for unknown types
  181. end
  182. end
  183. # rubocop:enable Lint/DuplicateBranch
  184. # Generate certification content (for preview/generation)
  185. def certification_content
  186. {
  187. employee_name: employee.full_name,
  188. employee_number: employee.employee_number,
  189. job_title: include_position? ? employee.job_title : nil,
  190. department: include_department? ? employee.department : nil,
  191. hire_date: include_start_date? ? employee.hire_date : nil,
  192. employment_status: employee.employment_status,
  193. employment_type: employee.employment_type,
  194. addressee: addressee || "A quien corresponda",
  195. language: language,
  196. certification_type: certification_type,
  197. purpose: purpose,
  198. generated_at: Time.current
  199. }.compact
  200. end
  201. private
  202. def salary_permission_required
  203. return unless include_salary? && certification_type != TYPE_SALARY
  204. errors.add(:include_salary, "requires salary certification type") unless certification_type == TYPE_FULL
  205. end
  206. def generate_request_number
  207. return if request_number.present?
  208. year = Date.current.year
  209. sequence = EmploymentCertificationRequest
  210. .where(organization_id: organization_id)
  211. .where(:created_at.gte => Date.new(year, 1, 1))
  212. .count + 1
  213. self.request_number = "CERT-#{year}-#{sequence.to_s.rjust(5, "0")}"
  214. end
  215. def set_submitted_at
  216. self.submitted_at ||= Time.current
  217. end
  218. def record_history(action, actor, details = nil)
  219. history << {
  220. "action" => action,
  221. "at" => Time.current.iso8601,
  222. "actor_id" => actor&.id&.to_s,
  223. "actor_name" => actor&.full_name,
  224. "details" => details
  225. }.compact
  226. end
  227. def log_request_created
  228. log_audit_event("certification_request_created", employee)
  229. end
  230. def log_audit_event(action, actor, metadata = {})
  231. Audit::AuditEvent.log(
  232. event_type: "hr",
  233. action: action,
  234. target: self,
  235. actor: actor&.user,
  236. metadata: metadata.merge(
  237. request_number: request_number,
  238. employee_name: employee.full_name,
  239. certification_type: certification_type,
  240. purpose: purpose
  241. ),
  242. tags: ["hr", "certification", action]
  243. )
  244. end
  245. class InvalidStateError < StandardError; end
  246. class AuthorizationError < StandardError; end
  247. class ValidationError < StandardError; end
  248. end
  249. # rubocop:enable Metrics/ClassLength
  250. end

app/models/hr/vacation_request.rb

80.09% lines covered

216 relevant lines. 173 lines covered and 43 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Hr
  3. # Vacation/PTO request with approval workflow
  4. # Requires supervisor approval, enforces balance rules
  5. #
  6. # rubocop:disable Metrics/ClassLength
  7. 1 class VacationRequest
  8. 1 include Mongoid::Document
  9. 1 include Mongoid::Timestamps
  10. 1 include UuidIdentifiable
  11. 1 store_in collection: "hr_vacation_requests"
  12. # Status constants
  13. 1 STATUS_DRAFT = "draft"
  14. 1 STATUS_PENDING = "pending" # Solicitada
  15. 1 STATUS_APPROVED = "approved" # Aprobada (programada, aún no disfrutada)
  16. 1 STATUS_ENJOYED = "enjoyed" # Disfrutada (vacaciones ya tomadas)
  17. 1 STATUS_REJECTED = "rejected"
  18. 1 STATUS_CANCELLED = "cancelled"
  19. 1 STATUSES = [STATUS_DRAFT, STATUS_PENDING, STATUS_APPROVED, STATUS_ENJOYED, STATUS_REJECTED, STATUS_CANCELLED].freeze
  20. STATUS_LABELS = {
  21. 1 STATUS_DRAFT => "Borrador",
  22. STATUS_PENDING => "Solicitada",
  23. STATUS_APPROVED => "Aprobada",
  24. STATUS_ENJOYED => "Disfrutada",
  25. STATUS_REJECTED => "Rechazada",
  26. STATUS_CANCELLED => "Cancelada"
  27. }.freeze
  28. # Vacation type constants
  29. 1 TYPE_VACATION = "vacation"
  30. 1 TYPE_PERSONAL = "personal"
  31. 1 TYPE_SICK = "sick"
  32. 1 TYPE_BEREAVEMENT = "bereavement"
  33. 1 TYPE_UNPAID = "unpaid"
  34. 1 VACATION_TYPES = [TYPE_VACATION, TYPE_PERSONAL, TYPE_SICK, TYPE_BEREAVEMENT, TYPE_UNPAID].freeze
  35. # Fields
  36. 1 field :request_number, type: String
  37. 1 field :vacation_type, type: String, default: TYPE_VACATION
  38. 1 field :start_date, type: Date
  39. 1 field :end_date, type: Date
  40. 1 field :days_requested, type: Float
  41. 1 field :reason, type: String
  42. 1 field :status, type: String, default: STATUS_DRAFT
  43. 1 field :notes, type: String
  44. # Approval fields
  45. 1 field :submitted_at, type: Time
  46. 1 field :decided_at, type: Time
  47. 1 field :decision_reason, type: String
  48. 1 field :approved_by_name, type: String
  49. # Generated document reference
  50. 1 field :document_uuid, type: String
  51. # History tracking
  52. 1 field :history, type: Array, default: []
  53. # Indexes
  54. 1 index({ uuid: 1 }, { unique: true })
  55. 1 index({ request_number: 1 }, { unique: true, sparse: true })
  56. 1 index({ employee_id: 1 })
  57. 1 index({ approver_id: 1 })
  58. 1 index({ organization_id: 1 })
  59. 1 index({ status: 1 })
  60. 1 index({ start_date: 1 })
  61. 1 index({ end_date: 1 })
  62. 1 index({ organization_id: 1, status: 1 })
  63. 1 index({ employee_id: 1, status: 1 })
  64. 1 index({ approver_id: 1, status: 1 })
  65. # Associations
  66. 1 belongs_to :employee, class_name: "Hr::Employee"
  67. 1 belongs_to :approver, class_name: "Hr::Employee", optional: true
  68. 1 belongs_to :organization, class_name: "Identity::Organization"
  69. # Validations
  70. 1 validates :vacation_type, inclusion: { in: VACATION_TYPES }
  71. 1 validates :status, inclusion: { in: STATUSES }
  72. 1 validates :start_date, presence: true
  73. 1 validates :end_date, presence: true
  74. 1 validates :days_requested, presence: true, numericality: { greater_than: 0 }
  75. 1 validate :end_date_after_start_date
  76. 1 validate :dates_not_in_past, on: :create
  77. 1 validate :sufficient_balance, on: :create, if: :requires_balance_check?
  78. 1 validate :no_overlapping_requests, if: :dates_changed?
  79. # Callbacks
  80. 1 before_create :generate_request_number
  81. 1 after_create :log_request_created
  82. # Scopes
  83. 1 scope :draft, -> { where(status: STATUS_DRAFT) }
  84. 1 scope :pending, -> { where(status: STATUS_PENDING) }
  85. 1 scope :approved, -> { where(status: STATUS_APPROVED) }
  86. 1 scope :enjoyed, -> { where(status: STATUS_ENJOYED) }
  87. 1 scope :rejected, -> { where(status: STATUS_REJECTED) }
  88. 1 scope :cancelled, -> { where(status: STATUS_CANCELLED) }
  89. 1 scope :active, -> { where(:status.in => [STATUS_PENDING, STATUS_APPROVED]) }
  90. 1 scope :decided, -> { where(:status.in => [STATUS_APPROVED, STATUS_REJECTED]) }
  91. 1 scope :scheduled, -> { approved.where(:start_date.gt => Date.current) } # Programadas (futuras)
  92. 1 scope :in_progress, -> { approved.where(:start_date.lte => Date.current, :end_date.gte => Date.current) }
  93. 11 scope :used, -> { where(:status.in => [STATUS_APPROVED, STATUS_ENJOYED]) } # Consumen balance
  94. 1 scope :for_approval_by, ->(employee) { pending.where(approver_id: employee.id) }
  95. 1 scope :upcoming, -> { approved.where(:start_date.gte => Date.current) }
  96. 1 scope :past, -> { approved.where(:end_date.lt => Date.current) }
  97. 1 scope :current, -> { approved.where(:start_date.lte => Date.current, :end_date.gte => Date.current) }
  98. 1 scope :in_date_range, ->(start_d, end_d) { where(:start_date.lte => end_d, :end_date.gte => start_d) }
  99. 1 scope :overlapping, ->(start_d, end_d) { approved.in_date_range(start_d, end_d) }
  100. # State predicates
  101. 1 def draft?
  102. status == STATUS_DRAFT
  103. end
  104. 1 def pending?
  105. 2 status == STATUS_PENDING
  106. end
  107. 1 def approved?
  108. 1 status == STATUS_APPROVED
  109. end
  110. 1 def rejected?
  111. 1 status == STATUS_REJECTED
  112. end
  113. 1 def cancelled?
  114. status == STATUS_CANCELLED
  115. end
  116. 1 def enjoyed?
  117. status == STATUS_ENJOYED
  118. end
  119. 1 def decided?
  120. approved? || rejected?
  121. end
  122. 1 def status_label
  123. STATUS_LABELS[status] || status
  124. end
  125. # Check if vacation period has passed and should be marked as enjoyed
  126. 1 def should_mark_as_enjoyed?
  127. approved? && end_date < Date.current
  128. end
  129. # Submit request for approval
  130. 1 def submit!(actor:)
  131. raise InvalidStateError, "Can only submit draft requests" unless draft?
  132. raise ValidationError, "Insufficient vacation balance" unless has_sufficient_balance?
  133. self.status = STATUS_PENDING
  134. self.submitted_at = Time.current
  135. self.approver = determine_approver
  136. record_history("submitted", actor)
  137. save!
  138. log_audit_event("vacation_request_submitted", actor)
  139. self
  140. end
  141. # Approve request (by supervisor or HR)
  142. # rubocop:disable Naming/PredicateMethod
  143. 1 def approve!(actor:, reason: nil)
  144. 1 raise InvalidStateError, "Can only approve pending requests" unless pending?
  145. 1 raise AuthorizationError, "Not authorized to approve" unless can_approve?(actor)
  146. 1 self.status = STATUS_APPROVED
  147. 1 self.decided_at = Time.current
  148. 1 self.decision_reason = reason
  149. 1 self.approved_by_name = actor.full_name
  150. # Deduct vacation balance
  151. 1 employee.deduct_vacation!(days_requested) if deducts_balance?
  152. 1 record_history("approved", actor, reason)
  153. 1 save!
  154. 1 log_audit_event("vacation_request_approved", actor, { reason: reason })
  155. 1 true
  156. end
  157. # Reject request
  158. 1 def reject!(actor:, reason:)
  159. 1 raise InvalidStateError, "Can only reject pending requests" unless pending?
  160. 1 raise AuthorizationError, "Not authorized to reject" unless can_approve?(actor)
  161. 1 raise ValidationError, "Rejection reason is required" if reason.blank?
  162. 1 self.status = STATUS_REJECTED
  163. 1 self.decided_at = Time.current
  164. 1 self.decision_reason = reason
  165. 1 record_history("rejected", actor, reason)
  166. 1 save!
  167. 1 log_audit_event("vacation_request_rejected", actor, { reason: reason })
  168. 1 true
  169. end
  170. # Cancel request (by employee or HR)
  171. 1 def cancel!(actor:, reason: nil)
  172. 1 raise InvalidStateError, "Cannot cancel decided requests" if rejected?
  173. 1 was_approved = approved?
  174. 1 self.status = STATUS_CANCELLED
  175. 1 self.decided_at = Time.current
  176. 1 self.decision_reason = reason
  177. # Restore vacation balance if was approved
  178. 1 employee.restore_vacation!(days_requested) if was_approved && deducts_balance?
  179. 1 record_history("cancelled", actor, reason)
  180. 1 save!
  181. 1 log_audit_event("vacation_request_cancelled", actor, {
  182. reason: reason,
  183. was_approved: was_approved
  184. })
  185. 1 true
  186. end
  187. # Mark vacation as enjoyed (after end_date has passed)
  188. 1 def mark_as_enjoyed!(actor: nil)
  189. raise InvalidStateError, "Can only mark approved vacations as enjoyed" unless approved?
  190. raise InvalidStateError, "Cannot mark as enjoyed before end date" if end_date >= Date.current
  191. self.status = STATUS_ENJOYED
  192. record_history("enjoyed", actor)
  193. save!
  194. log_audit_event("vacation_request_enjoyed", actor) if actor
  195. true
  196. end
  197. # Class method to auto-mark past approved vacations as enjoyed
  198. 1 def self.mark_past_vacations_as_enjoyed!
  199. approved.where(:end_date.lt => Date.current).each do |vacation|
  200. vacation.mark_as_enjoyed!
  201. rescue InvalidStateError
  202. # Skip if already processed
  203. next
  204. end
  205. end
  206. # Check if actor can approve this request
  207. 1 def can_approve?(actor)
  208. # Must be in same organization
  209. 2 return false unless actor.organization_id == organization_id
  210. 2 return true if actor.hr_manager?
  211. 2 return true if actor.hr_staff? && employee.supervisor.nil?
  212. 2 actor.supervises?(employee)
  213. end
  214. # Check if actor can view this request
  215. 1 def can_view?(actor)
  216. return true if actor.hr_staff?
  217. return true if actor.id == employee.id
  218. return true if actor.supervises?(employee)
  219. employee.supervisor_chain.include?(actor)
  220. end
  221. # Check if actor can cancel this request
  222. # rubocop:disable Metrics/PerceivedComplexity
  223. 1 def can_cancel?(actor)
  224. return false if rejected?
  225. return true if actor.hr_manager?
  226. return true if actor.id == employee.id && (draft? || pending?)
  227. return true if actor.id == employee.id && approved? && start_date > Date.current
  228. false
  229. end
  230. # rubocop:enable Metrics/PerceivedComplexity
  231. # rubocop:enable Naming/PredicateMethod
  232. # Calculate business days (simplified - excludes weekends)
  233. 1 def business_days
  234. return days_requested if days_requested
  235. count = 0
  236. (start_date..end_date).each do |date|
  237. count += 1 unless date.saturday? || date.sunday?
  238. end
  239. count
  240. end
  241. 1 private
  242. 1 def end_date_after_start_date
  243. 9 return unless start_date && end_date
  244. 9 errors.add(:end_date, "must be after or equal to start date") if end_date < start_date
  245. end
  246. 1 def dates_not_in_past
  247. 4 return unless start_date
  248. # Allow 1 day tolerance for timezone differences (UTC vs local time)
  249. 4 errors.add(:start_date, "cannot be in the past") if start_date < Date.current - 1.day
  250. end
  251. 1 def no_overlapping_requests
  252. 4 return unless employee && start_date && end_date
  253. # Find other requests from the same employee that overlap with these dates
  254. # Exclude cancelled and rejected requests, and exclude self (for updates)
  255. 4 overlapping = employee.vacation_requests
  256. .where(:status.nin => [STATUS_CANCELLED, STATUS_REJECTED])
  257. .where(:id.ne => id)
  258. .where(:start_date.lte => end_date, :end_date.gte => start_date)
  259. 4 return unless overlapping.exists?
  260. overlapping_request = overlapping.first
  261. errors.add(:base, "Ya tienes una solicitud (#{overlapping_request.request_number}) que incluye estas fechas")
  262. end
  263. 1 def dates_changed?
  264. 9 new_record? || start_date_changed? || end_date_changed?
  265. end
  266. 1 def sufficient_balance
  267. 4 return unless employee && days_requested
  268. 4 return if has_sufficient_balance?
  269. 1 errors.add(:days_requested, "exceeds available vacation balance")
  270. end
  271. 1 def has_sufficient_balance? # rubocop:disable Naming/PredicatePrefix
  272. 4 return true unless requires_balance_check?
  273. 4 employee.has_vacation_balance?(days_requested)
  274. end
  275. 1 def requires_balance_check?
  276. 8 [TYPE_VACATION, TYPE_PERSONAL].include?(vacation_type)
  277. end
  278. 1 def deducts_balance?
  279. 2 [TYPE_VACATION, TYPE_PERSONAL].include?(vacation_type)
  280. end
  281. 1 def determine_approver
  282. employee.supervisor || find_hr_manager
  283. end
  284. 1 def find_hr_manager
  285. Hr::Employee
  286. .where(organization_id: organization_id)
  287. .active
  288. .detect(&:hr_manager?)
  289. end
  290. 1 def generate_request_number
  291. 3 return if request_number.present?
  292. 3 year = Date.current.year
  293. 3 sequence = VacationRequest.where(organization_id: organization_id)
  294. .where(:created_at.gte => Date.new(year, 1, 1))
  295. .count + 1
  296. 3 self.request_number = "VAC-#{year}-#{sequence.to_s.rjust(5, "0")}"
  297. end
  298. 1 def record_history(action, actor, details = nil)
  299. 3 history << {
  300. "action" => action,
  301. "at" => Time.current.iso8601,
  302. "actor_id" => actor&.id&.to_s,
  303. "actor_name" => actor&.full_name,
  304. "details" => details
  305. }.compact
  306. end
  307. 1 def log_request_created
  308. 3 log_audit_event("vacation_request_created", employee)
  309. end
  310. 1 def log_audit_event(action, actor, metadata = {})
  311. 6 Audit::AuditEvent.log(
  312. event_type: "hr",
  313. action: action,
  314. target: self,
  315. actor: actor&.user,
  316. metadata: metadata.merge(
  317. request_number: request_number,
  318. employee_name: employee.full_name,
  319. vacation_type: vacation_type,
  320. days_requested: days_requested,
  321. start_date: start_date&.iso8601,
  322. end_date: end_date&.iso8601
  323. ),
  324. tags: ["hr", "vacation", action]
  325. )
  326. end
  327. 1 class InvalidStateError < StandardError; end
  328. 1 class AuthorizationError < StandardError; end
  329. 1 class ValidationError < StandardError; end
  330. end
  331. # rubocop:enable Metrics/ClassLength
  332. end

app/models/identity/jwt_denylist.rb

77.78% lines covered

18 relevant lines. 14 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Identity
  3. 1 class JwtDenylist
  4. 1 include Mongoid::Document
  5. 1 include Mongoid::Timestamps::Created
  6. 1 store_in collection: "jwt_denylists"
  7. 1 field :jti, type: String
  8. 1 field :exp, type: Time
  9. 1 index({ jti: 1 }, { unique: true })
  10. 1 index({ exp: 1 }, { expire_after_seconds: 0 })
  11. 1 validates :jti, presence: true, uniqueness: true
  12. 1 class << self
  13. 1 def jwt_revoked?(payload, _user)
  14. exists?(jti: payload["jti"])
  15. end
  16. 1 def revoke_jwt(payload, _user)
  17. find_or_create_by(jti: payload["jti"]) do |record|
  18. record.exp = Time.zone.at(payload["exp"].to_i)
  19. end
  20. end
  21. 1 def cleanup_expired!
  22. where(:exp.lt => Time.current).delete_all
  23. end
  24. end
  25. end
  26. end

app/models/identity/organization.rb

85.71% lines covered

56 relevant lines. 48 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Identity
  3. 1 class Organization
  4. 1 include Mongoid::Document
  5. 1 include Mongoid::Timestamps
  6. 1 include UuidIdentifiable
  7. 1 include SoftDeletable
  8. 1 include AuditTrackable
  9. 1 store_in collection: "organizations"
  10. # Fields
  11. 1 field :name, type: String
  12. 1 field :slug, type: String
  13. 1 field :settings, type: Hash, default: {}
  14. 1 field :active, type: Boolean, default: true
  15. # Organization details
  16. 1 field :legal_name, type: String
  17. 1 field :tax_id, type: String # NIT
  18. 1 field :address, type: String
  19. 1 field :city, type: String
  20. 1 field :country, type: String, default: 'Colombia'
  21. 1 field :phone, type: String
  22. 1 field :email, type: String
  23. 1 field :website, type: String
  24. 1 field :logo_url, type: String
  25. # HR Settings
  26. 1 field :vacation_days_per_year, type: Integer, default: 15
  27. 1 field :vacation_accrual_policy, type: String, default: 'monthly' # monthly, yearly
  28. 1 field :max_vacation_carryover, type: Integer, default: 15
  29. 1 field :probation_period_months, type: Integer, default: 2
  30. # Document Settings
  31. 1 field :allowed_file_types, type: Array, default: %w[pdf docx xlsx pptx jpg png]
  32. 1 field :max_file_size_mb, type: Integer, default: 25
  33. 1 field :document_retention_years, type: Integer, default: 10
  34. # Security Settings
  35. 1 field :session_timeout_minutes, type: Integer, default: 480
  36. 1 field :password_min_length, type: Integer, default: 8
  37. 1 field :password_require_uppercase, type: Boolean, default: true
  38. 1 field :password_require_number, type: Boolean, default: true
  39. 1 field :password_require_special, type: Boolean, default: false
  40. 1 field :max_login_attempts, type: Integer, default: 5
  41. # Indexes
  42. 1 index({ slug: 1 }, { unique: true })
  43. 1 index({ name: 1 })
  44. 1 index({ active: 1 })
  45. # Associations
  46. 1 has_many :users, class_name: "Identity::User", inverse_of: :organization
  47. 1 has_many :third_party_types, class_name: "Legal::ThirdPartyType", dependent: :destroy
  48. # Validations
  49. 1 validates :name, presence: true, length: { minimum: 2, maximum: 100 }
  50. 1 validates :slug, presence: true, uniqueness: true,
  51. format: { with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
  52. # Callbacks
  53. 1 before_validation :generate_slug, on: :create
  54. # Scopes
  55. 42 scope :active, -> { where(active: true) }
  56. 1 def activate!
  57. update!(active: true)
  58. end
  59. 1 def deactivate!
  60. update!(active: false)
  61. end
  62. 1 private
  63. 1 def generate_slug
  64. 13 return if slug.present?
  65. base_slug = name.to_s.parameterize
  66. self.slug = base_slug
  67. counter = 1
  68. while Identity::Organization.exists?(slug: slug)
  69. self.slug = "#{base_slug}-#{counter}"
  70. counter += 1
  71. end
  72. end
  73. end
  74. end

app/models/identity/permission.rb

0.0% lines covered

61 relevant lines. 0 lines covered and 61 lines missed.
    
  1. # frozen_string_literal: true
  2. module Identity
  3. class Permission
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. store_in collection: "permissions"
  7. # Fields
  8. field :name, type: String
  9. field :resource, type: String
  10. field :action, type: String
  11. field :description, type: String
  12. # Indexes
  13. index({ name: 1 }, { unique: true })
  14. index({ resource: 1, action: 1 }, { unique: true })
  15. # Associations
  16. has_and_belongs_to_many :roles, class_name: "Identity::Role", inverse_of: :permissions
  17. # Validations
  18. validates :name, presence: true, uniqueness: true
  19. validates :resource, presence: true
  20. validates :action, presence: true
  21. validates :resource, uniqueness: { scope: :action }
  22. # Standard actions
  23. ACTIONS = ["create", "read", "update", "delete", "manage", "export"].freeze
  24. # Standard resources
  25. RESOURCES = [
  26. "users", "roles", "organizations", "documents", "folders",
  27. "workflows", "audit_logs", "settings", "hr_requests", "legal_documents"
  28. ].freeze
  29. scope :for_resource, ->(resource) { where(resource: resource) }
  30. scope :for_action, ->(action) { where(action: action) }
  31. class << self
  32. def seed_defaults!
  33. default_permissions.each do |attrs|
  34. find_or_create_by!(name: attrs[:name]) do |p|
  35. p.resource = attrs[:resource]
  36. p.action = attrs[:action]
  37. p.description = attrs[:description]
  38. end
  39. end
  40. end
  41. private
  42. def default_permissions
  43. [
  44. # User management
  45. { name: "users.read", resource: "users", action: "read", description: "View users" },
  46. { name: "users.create", resource: "users", action: "create", description: "Create users" },
  47. { name: "users.update", resource: "users", action: "update", description: "Update users" },
  48. { name: "users.delete", resource: "users", action: "delete", description: "Delete users" },
  49. { name: "users.manage", resource: "users", action: "manage", description: "Full user management" },
  50. # Document management
  51. { name: "documents.read", resource: "documents", action: "read", description: "View documents" },
  52. { name: "documents.create", resource: "documents", action: "create", description: "Create documents" },
  53. { name: "documents.update", resource: "documents", action: "update", description: "Update documents" },
  54. { name: "documents.delete", resource: "documents", action: "delete", description: "Delete documents" },
  55. { name: "documents.manage", resource: "documents", action: "manage",
  56. description: "Full document management" },
  57. { name: "documents.export", resource: "documents", action: "export", description: "Export documents" },
  58. # Admin settings
  59. { name: "settings.read", resource: "settings", action: "read", description: "View settings" },
  60. { name: "settings.manage", resource: "settings", action: "manage", description: "Manage settings" },
  61. # HR management
  62. { name: "hr_requests.read", resource: "hr_requests", action: "read", description: "View HR requests" },
  63. { name: "hr_requests.manage", resource: "hr_requests", action: "manage", description: "Manage HR requests" },
  64. # Legal documents
  65. { name: "legal_documents.read", resource: "legal_documents", action: "read", description: "View legal docs" },
  66. { name: "legal_documents.manage", resource: "legal_documents", action: "manage",
  67. description: "Manage legal docs" },
  68. # Audit logs
  69. { name: "audit_logs.read", resource: "audit_logs", action: "read", description: "View audit logs" }
  70. ]
  71. end
  72. end
  73. end
  74. end

app/models/identity/role.rb

60.82% lines covered

97 relevant lines. 59 lines covered and 38 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Identity
  3. 1 class Role
  4. 1 include Mongoid::Document
  5. 1 include Mongoid::Timestamps
  6. 1 store_in collection: "roles"
  7. # Predefined role names
  8. 1 ADMIN = "admin"
  9. 1 CEO = "ceo"
  10. 1 GENERAL_MANAGER = "general_manager"
  11. 1 LEGAL_REPRESENTATIVE = "legal_representative"
  12. 1 LEGAL = "legal"
  13. 1 HR_MANAGER = "hr_manager"
  14. 1 HR = "hr"
  15. 1 ACCOUNTANT = "accountant"
  16. 1 MANAGER = "manager"
  17. 1 EMPLOYEE = "employee"
  18. 1 VIEWER = "viewer"
  19. 1 ALL_ROLES = [ADMIN, CEO, GENERAL_MANAGER, LEGAL_REPRESENTATIVE, LEGAL, HR_MANAGER, HR, ACCOUNTANT, MANAGER, EMPLOYEE, VIEWER].freeze
  20. # Permission levels (1-5 scale)
  21. 1 LEVEL_ADMIN = 5 # Full system access
  22. 1 LEVEL_LEGAL = 4 # Legal department access
  23. 1 LEVEL_HR = 3 # HR department access
  24. 1 LEVEL_EMPLOYEE = 2 # Standard employee access
  25. 1 LEVEL_VIEWER = 1 # Read-only access
  26. # Level to role mapping
  27. LEVELS = {
  28. 1 LEVEL_ADMIN => ADMIN,
  29. LEVEL_LEGAL => LEGAL,
  30. LEVEL_HR => HR,
  31. LEVEL_EMPLOYEE => EMPLOYEE,
  32. LEVEL_VIEWER => VIEWER
  33. }.freeze
  34. # Role to level mapping
  35. ROLE_LEVELS = {
  36. 1 ADMIN => LEVEL_ADMIN,
  37. CEO => LEVEL_ADMIN,
  38. GENERAL_MANAGER => LEVEL_ADMIN,
  39. LEGAL_REPRESENTATIVE => LEVEL_ADMIN,
  40. LEGAL => LEVEL_LEGAL,
  41. HR_MANAGER => LEVEL_LEGAL,
  42. HR => LEVEL_HR,
  43. ACCOUNTANT => LEVEL_HR,
  44. MANAGER => LEVEL_HR,
  45. EMPLOYEE => LEVEL_EMPLOYEE,
  46. VIEWER => LEVEL_VIEWER
  47. }.freeze
  48. # Fields
  49. 1 field :name, type: String
  50. 1 field :display_name, type: String
  51. 1 field :description, type: String
  52. 1 field :system_role, type: Boolean, default: false
  53. 1 field :level, type: Integer, default: 0
  54. # Indexes
  55. 1 index({ name: 1 }, { unique: true })
  56. 1 index({ level: -1 })
  57. # Associations
  58. 1 has_and_belongs_to_many :users, class_name: "Identity::User", inverse_of: :roles
  59. 1 has_and_belongs_to_many :permissions, class_name: "Identity::Permission", inverse_of: :roles
  60. # Validations
  61. 1 validates :name, presence: true, uniqueness: true
  62. 1 validates :display_name, presence: true
  63. # Scopes
  64. 1 scope :system_roles, -> { where(system_role: true) }
  65. 1 scope :custom_roles, -> { where(system_role: false) }
  66. 1 scope :by_level, -> { order(level: :desc) }
  67. 1 def admin?
  68. name == ADMIN
  69. end
  70. 1 def has_permission?(permission_name)
  71. permissions.exists?(name: permission_name)
  72. end
  73. 1 def can?(action, resource)
  74. # Admin can do everything
  75. return true if admin?
  76. # Check for manage permission (implies all actions)
  77. return true if permissions.exists?(resource: resource, action: "manage")
  78. # Check for specific permission
  79. permissions.exists?(resource: resource, action: action)
  80. end
  81. 1 def permission_names
  82. permissions.pluck(:name)
  83. end
  84. # Level comparison methods
  85. 1 def level_value
  86. ROLE_LEVELS[name] || level
  87. end
  88. 1 def level_name
  89. case level_value
  90. when LEVEL_ADMIN then "Admin"
  91. when LEVEL_LEGAL then "Legal"
  92. when LEVEL_HR then "HR"
  93. when LEVEL_EMPLOYEE then "Employee"
  94. when LEVEL_VIEWER then "Viewer"
  95. else "Custom (#{level_value})"
  96. end
  97. end
  98. 1 def higher_level_than?(other_role)
  99. level_value > other_role.level_value
  100. end
  101. 1 def same_level_as?(other_role)
  102. level_value == other_role.level_value
  103. end
  104. 1 def lower_level_than?(other_role)
  105. level_value < other_role.level_value
  106. end
  107. 1 def at_least_level?(min_level)
  108. level_value >= min_level
  109. end
  110. 1 def at_most_level?(max_level)
  111. level_value <= max_level
  112. end
  113. 1 class << self
  114. 1 def level_for(role_name)
  115. ROLE_LEVELS[role_name] || 0
  116. end
  117. 1 def role_for_level(level)
  118. LEVELS[level]
  119. end
  120. 1 def roles_at_level(min_level)
  121. ROLE_LEVELS.select { |_, v| v >= min_level }.keys
  122. end
  123. 1 def seed_defaults!
  124. default_roles.each do |attrs|
  125. role = find_or_create_by!(name: attrs[:name]) do |r|
  126. r.display_name = attrs[:display_name]
  127. r.description = attrs[:description]
  128. r.system_role = true
  129. r.level = attrs[:level]
  130. end
  131. # Update level if role already exists (for migration)
  132. if role.level != attrs[:level]
  133. role.update!(level: attrs[:level])
  134. end
  135. # Assign permissions
  136. assign_permissions(role, attrs[:permissions])
  137. end
  138. end
  139. 1 def find_by_name(name)
  140. find_by(name: name)
  141. end
  142. 1 def find_by_name!(name)
  143. find_by!(name: name)
  144. end
  145. 1 private
  146. 1 def assign_permissions(role, permission_names)
  147. return if permission_names.blank?
  148. permissions = Identity::Permission.where(:name.in => permission_names)
  149. role.permissions = permissions
  150. role.save!
  151. end
  152. 1 def default_roles
  153. [
  154. {
  155. name: ADMIN,
  156. display_name: "Administrador",
  157. description: "Acceso total al sistema (Nivel 5)",
  158. level: LEVEL_ADMIN,
  159. permissions: []
  160. },
  161. {
  162. name: CEO,
  163. display_name: "CEO",
  164. description: "Director Ejecutivo - Máxima autoridad (Nivel 5)",
  165. level: LEVEL_ADMIN,
  166. permissions: []
  167. },
  168. {
  169. name: GENERAL_MANAGER,
  170. display_name: "Gerente General",
  171. description: "Gerente general de la empresa (Nivel 5)",
  172. level: LEVEL_ADMIN,
  173. permissions: []
  174. },
  175. {
  176. name: LEGAL_REPRESENTATIVE,
  177. display_name: "Representante Legal",
  178. description: "Representante legal de la empresa - Firma documentos oficiales (Nivel 5)",
  179. level: LEVEL_ADMIN,
  180. permissions: []
  181. },
  182. {
  183. name: LEGAL,
  184. display_name: "Legal",
  185. description: "Departamento legal (Nivel 4)",
  186. level: LEVEL_LEGAL,
  187. permissions: [
  188. "documents.read", "documents.create", "documents.update", "documents.export",
  189. "legal_documents.read", "legal_documents.manage",
  190. "audit_logs.read"
  191. ]
  192. },
  193. {
  194. name: HR_MANAGER,
  195. display_name: "Gerente de RR.HH.",
  196. description: "Gerente de Recursos Humanos (Nivel 4)",
  197. level: LEVEL_LEGAL,
  198. permissions: [
  199. "documents.read", "documents.create", "documents.update",
  200. "hr_requests.read", "hr_requests.manage",
  201. "users.read", "users.manage"
  202. ]
  203. },
  204. {
  205. name: HR,
  206. display_name: "Recursos Humanos",
  207. description: "Personal de Recursos Humanos (Nivel 3)",
  208. level: LEVEL_HR,
  209. permissions: [
  210. "documents.read", "documents.create", "documents.update",
  211. "hr_requests.read", "hr_requests.manage",
  212. "users.read"
  213. ]
  214. },
  215. {
  216. name: ACCOUNTANT,
  217. display_name: "Contador",
  218. description: "Área contable y financiera (Nivel 3)",
  219. level: LEVEL_HR,
  220. permissions: [
  221. "documents.read", "documents.create", "documents.update"
  222. ]
  223. },
  224. {
  225. name: MANAGER,
  226. display_name: "Jefe de Área",
  227. description: "Jefe o supervisor de área (Nivel 3)",
  228. level: LEVEL_HR,
  229. permissions: [
  230. "documents.read", "documents.create", "documents.update",
  231. "hr_requests.read"
  232. ]
  233. },
  234. {
  235. name: EMPLOYEE,
  236. display_name: "Empleado",
  237. description: "Empleado estándar (Nivel 2)",
  238. level: LEVEL_EMPLOYEE,
  239. permissions: [
  240. "documents.read", "documents.create", "documents.update",
  241. "hr_requests.read"
  242. ]
  243. },
  244. {
  245. name: VIEWER,
  246. display_name: "Visor",
  247. description: "Solo lectura de documentos (Nivel 1)",
  248. level: LEVEL_VIEWER,
  249. permissions: [
  250. "documents.read"
  251. ]
  252. }
  253. ]
  254. end
  255. end
  256. end
  257. end

app/models/identity/user.rb

73.55% lines covered

121 relevant lines. 89 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Identity
  3. 1 class User
  4. 1 include Mongoid::Document
  5. 1 include Mongoid::Timestamps
  6. 1 include UuidIdentifiable
  7. 1 include SoftDeletable
  8. 1 include AuditTrackable
  9. # Devise modules
  10. 1 devise :database_authenticatable, :registerable,
  11. :recoverable, :rememberable, :validatable,
  12. :trackable, :lockable,
  13. :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
  14. # Devise-JWT compatibility for Mongoid (ActiveRecord compatibility)
  15. 1 def self.primary_key
  16. "_id"
  17. end
  18. 1 store_in collection: "users"
  19. # Basic fields
  20. 1 field :email, type: String
  21. 1 field :first_name, type: String
  22. 1 field :last_name, type: String
  23. 1 field :employee_id, type: String
  24. 1 field :department, type: String
  25. 1 field :title, type: String
  26. 1 field :phone, type: String
  27. 1 field :time_zone, type: String, default: "UTC"
  28. 1 field :locale, type: String, default: "en"
  29. 1 field :active, type: Boolean, default: true
  30. # Devise fields
  31. 1 field :encrypted_password, type: String, default: ""
  32. 1 field :reset_password_token, type: String
  33. 1 field :reset_password_sent_at, type: Time
  34. 1 field :remember_created_at, type: Time
  35. # Trackable
  36. 1 field :sign_in_count, type: Integer, default: 0
  37. 1 field :current_sign_in_at, type: Time
  38. 1 field :last_sign_in_at, type: Time
  39. 1 field :current_sign_in_ip, type: String
  40. 1 field :last_sign_in_ip, type: String
  41. # Lockable
  42. 1 field :failed_attempts, type: Integer, default: 0
  43. 1 field :unlock_token, type: String
  44. 1 field :locked_at, type: Time
  45. # Password change required (for new users created from contracts)
  46. 1 field :must_change_password, type: Boolean, default: false
  47. 1 field :password_changed_at, type: Time
  48. # Indexes
  49. 1 index({ email: 1 }, { unique: true })
  50. 1 index({ employee_id: 1 }, { sparse: true })
  51. 1 index({ reset_password_token: 1 }, { unique: true, sparse: true })
  52. 1 index({ unlock_token: 1 }, { unique: true, sparse: true })
  53. 1 index({ organization_id: 1 })
  54. 1 index({ active: 1 })
  55. 1 index({ last_name: 1, first_name: 1 })
  56. # Associations
  57. 1 belongs_to :organization, class_name: "Identity::Organization", inverse_of: :users, optional: true
  58. 1 has_and_belongs_to_many :roles, class_name: "Identity::Role", inverse_of: :users
  59. 1 has_many :signatures, class_name: "Identity::UserSignature", inverse_of: :user, dependent: :destroy
  60. # Validations
  61. 1 validates :email, presence: true, uniqueness: true
  62. 1 validates :first_name, presence: true, length: { maximum: 50 }
  63. 1 validates :last_name, presence: true, length: { maximum: 50 }
  64. 1 validates :employee_id, uniqueness: true, allow_blank: true
  65. # Scopes
  66. 1 scope :enabled, -> { where(active: true) }
  67. 1 scope :disabled, -> { where(active: false) }
  68. 1 scope :admins, -> { where(:role_ids.in => [Identity::Role.where(name: "admin").first&.id].compact) }
  69. # Callbacks
  70. 1 after_create :assign_default_role
  71. # Instance methods
  72. 1 def full_name
  73. 6 "#{first_name} #{last_name}".strip
  74. end
  75. 1 def initials
  76. "#{first_name&.first}#{last_name&.first}".upcase
  77. end
  78. 1 def admin?
  79. 4 roles.any?(&:admin?)
  80. end
  81. 1 def super_admin?
  82. admin? # For now, admin is super_admin
  83. end
  84. 1 def has_role?(role_name)
  85. 6 roles.exists?(name: role_name)
  86. end
  87. 1 def has_permission?(permission_name)
  88. return true if admin?
  89. roles.any? { |role| role.has_permission?(permission_name) }
  90. end
  91. 1 def can?(action, resource)
  92. return true if admin?
  93. roles.any? { |role| role.can?(action, resource) }
  94. end
  95. 1 def permission_names
  96. return ["*"] if admin? # Admin has all permissions
  97. roles.flat_map(&:permission_names).uniq
  98. end
  99. 1 def role_names
  100. roles.pluck(:name)
  101. end
  102. 1 def highest_role
  103. roles.by_level.first
  104. end
  105. # Permission level methods (1-5 scale)
  106. 1 def permission_level
  107. highest_role&.level_value || 0
  108. end
  109. 1 def level_name
  110. highest_role&.level_name || "None"
  111. end
  112. 1 def at_least_level?(min_level)
  113. permission_level >= min_level
  114. end
  115. 1 def at_most_level?(max_level)
  116. permission_level <= max_level
  117. end
  118. 1 def higher_level_than?(other_user)
  119. permission_level > other_user.permission_level
  120. end
  121. 1 def same_level_as?(other_user)
  122. permission_level == other_user.permission_level
  123. end
  124. 1 def lower_level_than?(other_user)
  125. permission_level < other_user.permission_level
  126. end
  127. # Level-specific helpers using constants
  128. 1 def viewer?
  129. has_role?(Identity::Role::VIEWER) || permission_level >= Identity::Role::LEVEL_VIEWER
  130. end
  131. 1 def employee?
  132. has_role?(Identity::Role::EMPLOYEE) || permission_level >= Identity::Role::LEVEL_EMPLOYEE
  133. end
  134. 1 def hr?
  135. has_role?(Identity::Role::HR) || permission_level >= Identity::Role::LEVEL_HR
  136. end
  137. 1 def legal?
  138. has_role?(Identity::Role::LEGAL) || permission_level >= Identity::Role::LEVEL_LEGAL
  139. end
  140. 1 def activate!
  141. update!(active: true)
  142. end
  143. 1 def deactivate!
  144. update!(active: false)
  145. end
  146. 1 def assign_role!(role_name)
  147. role = Identity::Role.find_by!(name: role_name)
  148. roles << role unless roles.include?(role)
  149. end
  150. 1 def remove_role!(role_name)
  151. role = Identity::Role.find_by!(name: role_name)
  152. roles.delete(role)
  153. end
  154. 1 def default_signature
  155. signatures.default_signature.first || signatures.first
  156. end
  157. 1 def has_signature?
  158. signatures.any?
  159. end
  160. # JWT payload customization
  161. 1 def jwt_payload
  162. {
  163. "user_id" => id.to_s,
  164. "email" => email,
  165. "roles" => role_names,
  166. "permission_level" => permission_level,
  167. "organization_id" => organization_id&.to_s,
  168. "must_change_password" => must_change_password
  169. }
  170. end
  171. # Mark password as changed
  172. 1 def password_changed!
  173. update!(must_change_password: false, password_changed_at: Time.current)
  174. end
  175. 1 private
  176. 1 def assign_default_role
  177. 18 return if roles.any?
  178. 18 default_role = Identity::Role.where(name: Identity::Role::EMPLOYEE).first
  179. 18 roles << default_role if default_role
  180. end
  181. end
  182. end

app/models/identity/user_signature.rb

0.0% lines covered

107 relevant lines. 0 lines covered and 107 lines missed.
    
  1. # frozen_string_literal: true
  2. module Identity
  3. class UserSignature
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. store_in collection: "user_signatures"
  8. # Signature types
  9. DRAWN = "drawn"
  10. STYLED = "styled"
  11. SIGNATURE_TYPES = [DRAWN, STYLED].freeze
  12. # Available fonts for styled signatures
  13. SIGNATURE_FONTS = [
  14. "Allura",
  15. "Dancing Script",
  16. "Great Vibes",
  17. "Pacifico",
  18. "Sacramento"
  19. ].freeze
  20. # Fields
  21. field :name, type: String # Name for this signature (e.g., "Formal", "Initials")
  22. field :signature_type, type: String # drawn or styled
  23. field :is_default, type: Boolean, default: false
  24. field :active, type: Boolean, default: true # Can be disabled instead of deleted if in use
  25. # For drawn signatures - stored as base64 PNG
  26. field :image_data, type: String # Base64 encoded PNG image
  27. # For styled signatures
  28. field :styled_text, type: String # The text to display
  29. field :font_family, type: String # Font name from SIGNATURE_FONTS
  30. field :font_color, type: String, default: "#000000"
  31. field :font_size, type: Integer, default: 48
  32. # Associations
  33. belongs_to :user, class_name: "Identity::User", inverse_of: :signatures
  34. # Indexes
  35. index({ user_id: 1 })
  36. index({ user_id: 1, is_default: 1 })
  37. index({ signature_type: 1 })
  38. # Validations
  39. validates :name, presence: true, length: { maximum: 100 }
  40. validates :signature_type, presence: true, inclusion: { in: SIGNATURE_TYPES }
  41. validates :image_data, presence: true, if: -> { signature_type == DRAWN }
  42. validates :styled_text, presence: true, if: -> { signature_type == STYLED }
  43. validates :font_family, presence: true, inclusion: { in: SIGNATURE_FONTS }, if: -> { signature_type == STYLED }
  44. validate :only_one_default_per_user
  45. # Scopes
  46. scope :drawn, -> { where(signature_type: DRAWN) }
  47. scope :styled, -> { where(signature_type: STYLED) }
  48. scope :default_signature, -> { where(is_default: true) }
  49. scope :active, -> { where(active: true) }
  50. scope :inactive, -> { where(active: false) }
  51. # Callbacks
  52. before_save :ensure_single_default
  53. before_destroy :prevent_destroy_if_in_use
  54. after_destroy :ensure_default_exists
  55. # Instance methods
  56. def drawn?
  57. signature_type == DRAWN
  58. end
  59. def styled?
  60. signature_type == STYLED
  61. end
  62. def set_as_default!
  63. user.signatures.update_all(is_default: false)
  64. update!(is_default: true)
  65. end
  66. # Check if this signature is used in any generated document
  67. def in_use?
  68. documents_using_count > 0
  69. end
  70. # Count documents using this signature
  71. def documents_using_count
  72. ::Templates::GeneratedDocument.where("signatures.signature_id" => uuid).count
  73. end
  74. # Get documents using this signature
  75. def documents_using
  76. ::Templates::GeneratedDocument.where("signatures.signature_id" => uuid)
  77. end
  78. # Disable signature (soft delete alternative)
  79. def disable!
  80. update!(active: false, is_default: false)
  81. end
  82. # Enable signature
  83. def enable!
  84. update!(active: true)
  85. end
  86. def active?
  87. active == true
  88. end
  89. def inactive?
  90. !active?
  91. end
  92. # Returns the signature as a renderable format for PDF
  93. def to_image_data
  94. if drawn?
  95. # Return the base64 PNG data directly
  96. image_data
  97. else
  98. # For styled signatures, we'll render server-side using MiniMagick
  99. render_styled_signature
  100. end
  101. end
  102. # Render styled signature as base64 PNG image
  103. def render_styled_signature
  104. return image_data if image_data.present? && styled?
  105. Templates::SignatureRendererService.new(self).render_styled
  106. end
  107. private
  108. def only_one_default_per_user
  109. return unless is_default && is_default_changed?
  110. if user&.signatures&.where(is_default: true)&.where(:id.ne => id)&.exists?
  111. # This is fine, we'll update in before_save callback
  112. end
  113. end
  114. def ensure_single_default
  115. return unless is_default && is_default_changed?
  116. user.signatures.where(:id.ne => id).update_all(is_default: false)
  117. end
  118. def ensure_default_exists
  119. return unless is_default
  120. return unless user.signatures.any?
  121. # Set the first remaining signature as default
  122. user.signatures.first.update!(is_default: true)
  123. end
  124. def prevent_destroy_if_in_use
  125. return unless in_use?
  126. errors.add(:base, "No se puede eliminar una firma que está siendo utilizada en documentos. Desactívela en su lugar.")
  127. throw(:abort)
  128. end
  129. end
  130. end

app/models/legal/contract.rb

0.0% lines covered

345 relevant lines. 0 lines covered and 345 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class Contract
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. # Constants
  8. TYPES = %w[
  9. services purchase nda lease partnership
  10. employment consulting maintenance license other
  11. ].freeze
  12. TYPE_LABELS = {
  13. "services" => "Prestación de Servicios",
  14. "purchase" => "Compraventa",
  15. "nda" => "Confidencialidad (NDA)",
  16. "lease" => "Arrendamiento",
  17. "partnership" => "Alianza/Asociación",
  18. "employment" => "Laboral",
  19. "consulting" => "Consultoría",
  20. "maintenance" => "Mantenimiento",
  21. "license" => "Licencia",
  22. "other" => "Otro"
  23. }.freeze
  24. STATUSES = %w[
  25. draft pending_approval approved pending_signatures
  26. active expired terminated cancelled rejected archived
  27. ].freeze
  28. STATUS_LABELS = {
  29. "draft" => "Borrador",
  30. "pending_approval" => "Pendiente de Aprobación",
  31. "approved" => "Aprobado",
  32. "pending_signatures" => "Pendiente de Firmas",
  33. "rejected" => "Rechazado",
  34. "active" => "Activo",
  35. "expired" => "Vencido",
  36. "terminated" => "Terminado",
  37. "cancelled" => "Cancelado",
  38. "archived" => "Archivado"
  39. }.freeze
  40. CURRENCIES = %w[COP USD EUR].freeze
  41. # Approval levels based on amount (in COP)
  42. APPROVAL_LEVELS = {
  43. "level_1" => { max_amount: 10_000_000, approvers: %w[area_manager], label: "Nivel 1 (≤$10M)" },
  44. "level_2" => { max_amount: 50_000_000, approvers: %w[area_manager legal], label: "Nivel 2 (≤$50M)" },
  45. "level_3" => { max_amount: 200_000_000, approvers: %w[area_manager legal general_manager], label: "Nivel 3 (≤$200M)" },
  46. "level_4" => { max_amount: Float::INFINITY, approvers: %w[area_manager legal general_manager ceo], label: "Nivel 4 (>$200M)" }
  47. }.freeze
  48. # Collection
  49. store_in collection: "legal_contracts"
  50. # Fields - Basic info
  51. field :contract_number, type: String
  52. field :title, type: String
  53. field :description, type: String
  54. field :contract_type, type: String, default: "services"
  55. field :status, type: String, default: "draft"
  56. # Dates
  57. field :start_date, type: Date
  58. field :end_date, type: Date
  59. field :signature_date, type: Date
  60. # Financial
  61. field :amount, type: BigDecimal
  62. field :currency, type: String, default: "COP"
  63. field :payment_terms, type: String
  64. field :payment_frequency, type: String # monthly, quarterly, annually, one_time
  65. # Approval workflow
  66. field :approval_level, type: String
  67. field :current_approver_role, type: String
  68. field :submitted_at, type: Time
  69. field :approved_at, type: Time
  70. field :rejected_at, type: Time
  71. field :rejection_reason, type: String
  72. # Document
  73. field :document_uuid, type: String
  74. field :template_id, type: String
  75. field :attachments, type: Array, default: [] # GridFS IDs
  76. # Renewal
  77. field :auto_renewal, type: Boolean, default: false
  78. field :renewal_notice_days, type: Integer, default: 30
  79. field :renewal_terms, type: String
  80. # History/Audit
  81. field :history, type: Array, default: []
  82. # Associations
  83. belongs_to :organization, class_name: "Identity::Organization"
  84. belongs_to :third_party, class_name: "Legal::ThirdParty"
  85. belongs_to :requested_by, class_name: "Identity::User"
  86. belongs_to :area_manager, class_name: "Identity::User", optional: true
  87. embeds_many :approvals, class_name: "Legal::ContractApproval"
  88. # Validations
  89. validates :contract_type, presence: true, inclusion: { in: TYPES }
  90. validates :status, presence: true, inclusion: { in: STATUSES }
  91. validates :currency, inclusion: { in: CURRENCIES }
  92. validates :title, presence: true
  93. validates :amount, presence: true, numericality: { greater_than: 0 }
  94. validates :start_date, presence: true
  95. validates :end_date, presence: true
  96. validate :end_date_after_start_date
  97. # Indexes
  98. index({ organization_id: 1, status: 1 })
  99. index({ organization_id: 1, contract_type: 1 })
  100. index({ contract_number: 1 }, unique: true)
  101. index({ third_party_id: 1 })
  102. index({ requested_by_id: 1 })
  103. index({ end_date: 1 })
  104. # Callbacks
  105. before_create :generate_contract_number
  106. before_save :determine_approval_level, if: :amount_changed?
  107. # Scopes
  108. scope :draft, -> { where(status: "draft") }
  109. scope :pending_approval, -> { where(status: "pending_approval") }
  110. scope :approved, -> { where(status: "approved") }
  111. scope :pending_signatures, -> { where(status: "pending_signatures") }
  112. scope :rejected, -> { where(status: "rejected") }
  113. scope :active, -> { where(status: "active") }
  114. scope :expired, -> { where(status: "expired") }
  115. scope :archived, -> { where(status: "archived") }
  116. scope :not_archived, -> { where.not(status: "archived") }
  117. scope :by_type, ->(type) { where(contract_type: type) }
  118. scope :by_third_party, ->(tp_id) { where(third_party_id: tp_id) }
  119. scope :expiring_soon, ->(days = 30) { active.where(:end_date.lte => Date.current + days) }
  120. scope :search, ->(query) {
  121. return all if query.blank?
  122. regex = /#{Regexp.escape(query)}/i
  123. any_of(
  124. { title: regex },
  125. { contract_number: regex },
  126. { description: regex }
  127. )
  128. }
  129. # State predicates
  130. def draft?; status == "draft"; end
  131. def pending_approval?; status == "pending_approval"; end
  132. def approved?; status == "approved"; end
  133. def pending_signatures?; status == "pending_signatures"; end
  134. def rejected?; status == "rejected"; end
  135. def active?; status == "active"; end
  136. def expired?; status == "expired"; end
  137. def terminated?; status == "terminated"; end
  138. def cancelled?; status == "cancelled"; end
  139. def archived?; status == "archived"; end
  140. def editable?
  141. draft?
  142. end
  143. def can_submit?
  144. draft? && valid?
  145. end
  146. def can_activate?
  147. approved? || (pending_signatures? && document_all_signed?)
  148. end
  149. # Document and signature helpers
  150. def generated_document
  151. return nil unless document_uuid
  152. @generated_document ||= ::Templates::GeneratedDocument.find_by(uuid: document_uuid)
  153. end
  154. def document_has_pending_signatures?
  155. doc = generated_document
  156. return false unless doc
  157. doc.pending_signatures? && doc.pending_signatures_count > 0
  158. end
  159. def document_all_signed?
  160. doc = generated_document
  161. return true unless doc # No document = nothing to sign
  162. return true unless doc.signatures.any? # No signatures configured
  163. doc.all_required_signed?
  164. end
  165. def document_pending_signatures_count
  166. generated_document&.pending_signatures_count || 0
  167. end
  168. def document_signatures_status
  169. doc = generated_document
  170. return nil unless doc
  171. {
  172. total: doc.total_required_signatures,
  173. signed: doc.completed_signatures_count,
  174. pending: doc.pending_signatures_count,
  175. all_signed: doc.all_required_signed?
  176. }
  177. end
  178. # Workflow methods
  179. def submit!(actor:)
  180. raise InvalidStateError, "Solo se pueden enviar contratos en borrador" unless draft?
  181. raise ValidationError, "El contrato no es válido" unless valid?
  182. determine_approval_level
  183. initialize_approvals!
  184. self.status = "pending_approval"
  185. self.submitted_at = Time.current
  186. self.current_approver_role = required_approvers.first
  187. record_history("submitted", actor, { approval_level: approval_level })
  188. save!
  189. end
  190. def approve!(actor:, role:, notes: nil)
  191. raise InvalidStateError, "Solo se pueden aprobar contratos pendientes" unless pending_approval?
  192. approval = approvals.find { |a| a.role == role && a.pending? }
  193. raise AuthorizationError, "No hay aprobación pendiente para el rol #{role}" unless approval
  194. raise AuthorizationError, "No es tu turno de aprobar" unless current_approver_role == role
  195. raise AuthorizationError, "No tienes permisos para aprobar como #{role}" unless approval.can_be_decided_by?(actor)
  196. approval.approve!(actor: actor, notes: notes)
  197. record_history("approved_by", actor, { role: role, notes: notes })
  198. next_role = next_approver_role
  199. if next_role
  200. self.current_approver_role = next_role
  201. else
  202. # All approvals complete - check if document requires signatures
  203. if document_has_pending_signatures?
  204. self.status = "pending_signatures"
  205. record_history("pending_signatures", actor, { message: "Esperando firmas del documento" })
  206. else
  207. self.status = "approved"
  208. end
  209. self.approved_at = Time.current
  210. self.current_approver_role = nil
  211. end
  212. save!
  213. end
  214. def reject!(actor:, role:, reason:)
  215. raise InvalidStateError, "Solo se pueden rechazar contratos pendientes" unless pending_approval?
  216. raise ArgumentError, "Se requiere un motivo de rechazo" if reason.blank?
  217. approval = approvals.find { |a| a.role == role && a.pending? }
  218. raise AuthorizationError, "No hay aprobación pendiente para el rol #{role}" unless approval
  219. raise AuthorizationError, "No tienes permisos para rechazar como #{role}" unless approval.can_be_decided_by?(actor)
  220. approval.reject!(actor: actor, reason: reason)
  221. self.status = "rejected"
  222. self.rejected_at = Time.current
  223. self.rejection_reason = reason
  224. self.current_approver_role = nil
  225. record_history("rejected", actor, { role: role, reason: reason })
  226. save!
  227. end
  228. def activate!(actor: nil)
  229. raise InvalidStateError, "Solo se pueden activar contratos aprobados" unless can_activate?
  230. if pending_signatures? && !document_all_signed?
  231. raise InvalidStateError, "El documento tiene firmas pendientes"
  232. end
  233. self.status = "active"
  234. record_history("activated", actor) if actor
  235. save!
  236. end
  237. def terminate!(actor:, reason: nil)
  238. raise InvalidStateError, "Solo se pueden terminar contratos activos" unless active?
  239. self.status = "terminated"
  240. record_history("terminated", actor, { reason: reason })
  241. save!
  242. end
  243. def cancel!(actor:, reason: nil)
  244. raise InvalidStateError, "No se puede cancelar este contrato" if active? || expired? || terminated?
  245. self.status = "cancelled"
  246. record_history("cancelled", actor, { reason: reason })
  247. save!
  248. end
  249. def expire!
  250. return unless active? && end_date && end_date < Date.current
  251. self.status = "expired"
  252. record_history("expired", nil, { expired_on: Date.current })
  253. save!
  254. end
  255. def archive!(actor:)
  256. # Can archive completed contracts (active, expired, terminated, cancelled)
  257. archivable_statuses = %w[active expired terminated cancelled]
  258. raise InvalidStateError, "Solo se pueden archivar contratos completados" unless archivable_statuses.include?(status)
  259. self.status = "archived"
  260. record_history("archived", actor, { archived_at: Time.current })
  261. save!
  262. end
  263. def unarchive!(actor:)
  264. raise InvalidStateError, "El contrato no está archivado" unless archived?
  265. # Restore to expired status (safest default for archived contracts)
  266. self.status = "expired"
  267. record_history("unarchived", actor, { unarchived_at: Time.current })
  268. save!
  269. end
  270. def complete_signatures!(actor:)
  271. raise InvalidStateError, "Solo aplica para contratos pendientes de firmas" unless pending_signatures?
  272. raise InvalidStateError, "El documento tiene firmas pendientes" unless document_all_signed?
  273. self.status = "approved"
  274. record_history("signatures_completed", actor, { message: "Todas las firmas han sido completadas" })
  275. save!
  276. end
  277. # Approval helpers
  278. def required_approvers
  279. return [] unless approval_level
  280. APPROVAL_LEVELS.dig(approval_level, :approvers) || []
  281. end
  282. def next_approver_role
  283. approved_roles = approvals.select(&:approved?).map(&:role)
  284. required_approvers.find { |r| !approved_roles.include?(r) }
  285. end
  286. def approval_progress
  287. return 0 if approvals.empty?
  288. (approvals.count(&:approved?).to_f / approvals.count * 100).round
  289. end
  290. def can_approve?(user)
  291. return false unless pending_approval?
  292. return false unless current_approver_role
  293. approval = approvals.find { |a| a.role == current_approver_role && a.pending? }
  294. approval&.can_be_decided_by?(user) || false
  295. end
  296. def pending_approval_for_user?(user)
  297. can_approve?(user)
  298. end
  299. # Labels
  300. def type_label
  301. TYPE_LABELS[contract_type] || contract_type.humanize
  302. end
  303. def status_label
  304. STATUS_LABELS[status] || status.humanize
  305. end
  306. def approval_level_label
  307. APPROVAL_LEVELS.dig(approval_level, :label) || approval_level
  308. end
  309. def current_approver_label
  310. return nil unless current_approver_role
  311. ContractApproval::ROLE_LABELS[current_approver_role] || current_approver_role.humanize
  312. end
  313. # Duration
  314. def duration_days
  315. return nil unless start_date && end_date
  316. (end_date - start_date).to_i
  317. end
  318. def days_until_expiry
  319. return nil unless end_date
  320. (end_date - Date.current).to_i
  321. end
  322. def expiring_soon?(days = 30)
  323. active? && days_until_expiry && days_until_expiry <= days
  324. end
  325. # Errors
  326. class InvalidStateError < StandardError; end
  327. class AuthorizationError < StandardError; end
  328. class ValidationError < StandardError; end
  329. private
  330. def generate_contract_number
  331. return if contract_number.present?
  332. year = Time.current.year
  333. last_record = self.class
  334. .where(organization_id: organization_id)
  335. .where(:contract_number.ne => nil)
  336. .order(created_at: :desc)
  337. .first
  338. if last_record&.contract_number&.match?(/CON-#{year}-(\d+)/)
  339. last_num = last_record.contract_number.split("-").last.to_i
  340. self.contract_number = "CON-#{year}-#{(last_num + 1).to_s.rjust(5, '0')}"
  341. else
  342. self.contract_number = "CON-#{year}-00001"
  343. end
  344. end
  345. def determine_approval_level
  346. return unless amount
  347. amount_cop = amount.to_f
  348. level = APPROVAL_LEVELS.find { |_, config| amount_cop <= config[:max_amount] }
  349. self.approval_level = level&.first || "level_4"
  350. end
  351. def initialize_approvals!
  352. self.approvals = []
  353. required_approvers.each_with_index do |role, index|
  354. approvals.build(role: role, status: "pending", order: index)
  355. end
  356. end
  357. def record_history(action, actor, details = {})
  358. history << {
  359. action: action,
  360. actor_id: actor&.id&.to_s,
  361. actor_name: actor&.full_name,
  362. timestamp: Time.current.iso8601,
  363. details: details
  364. }
  365. end
  366. def end_date_after_start_date
  367. return unless start_date && end_date
  368. errors.add(:end_date, "debe ser posterior a la fecha de inicio") if end_date < start_date
  369. end
  370. end
  371. end

app/models/legal/contract_approval.rb

0.0% lines covered

75 relevant lines. 0 lines covered and 75 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class ContractApproval
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. # Constants
  7. STATUSES = %w[pending approved rejected].freeze
  8. ROLES = %w[area_manager legal general_manager ceo].freeze
  9. ROLE_LABELS = {
  10. "area_manager" => "Jefe de Área",
  11. "legal" => "Legal",
  12. "general_manager" => "Gerente General",
  13. "ceo" => "CEO"
  14. }.freeze
  15. # Fields
  16. field :role, type: String
  17. field :status, type: String, default: "pending"
  18. field :order, type: Integer, default: 0
  19. field :decided_at, type: Time
  20. field :notes, type: String
  21. field :reason, type: String # For rejections
  22. # Approver info (captured at decision time)
  23. field :approver_id, type: String
  24. field :approver_name, type: String
  25. field :approver_email, type: String
  26. # Embedded in Contract
  27. embedded_in :contract, class_name: "Legal::Contract"
  28. # Validations
  29. validates :role, presence: true, inclusion: { in: ROLES }
  30. validates :status, presence: true, inclusion: { in: STATUSES }
  31. # Instance methods
  32. def pending?
  33. status == "pending"
  34. end
  35. def approved?
  36. status == "approved"
  37. end
  38. def rejected?
  39. status == "rejected"
  40. end
  41. def decided?
  42. approved? || rejected?
  43. end
  44. def role_label
  45. ROLE_LABELS[role] || role.humanize
  46. end
  47. def approve!(actor:, notes: nil)
  48. self.status = "approved"
  49. self.decided_at = Time.current
  50. self.approver_id = actor.id.to_s
  51. self.approver_name = actor.full_name
  52. self.approver_email = actor.email
  53. self.notes = notes
  54. end
  55. def reject!(actor:, reason:)
  56. self.status = "rejected"
  57. self.decided_at = Time.current
  58. self.approver_id = actor.id.to_s
  59. self.approver_name = actor.full_name
  60. self.approver_email = actor.email
  61. self.reason = reason
  62. end
  63. def can_be_decided_by?(user)
  64. pending? && user_has_approval_role?(user)
  65. end
  66. private
  67. def user_has_approval_role?(user)
  68. case role
  69. when "area_manager"
  70. user.has_role?("manager") || user.has_role?("admin")
  71. when "legal"
  72. user.has_role?("legal") || user.has_role?("admin")
  73. when "general_manager"
  74. user.has_role?("general_manager") || user.has_role?("admin")
  75. when "ceo"
  76. user.has_role?("ceo") || user.has_role?("admin")
  77. else
  78. false
  79. end
  80. end
  81. end
  82. end

app/models/legal/third_party.rb

0.0% lines covered

157 relevant lines. 0 lines covered and 157 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class ThirdParty
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. # Constants
  8. TYPES = %w[provider client contractor partner other].freeze
  9. PERSON_TYPES = %w[natural juridical].freeze
  10. STATUSES = %w[active inactive blocked].freeze
  11. IDENTIFICATION_TYPES = %w[NIT CC CE PA TI NIP].freeze
  12. # Collection
  13. store_in collection: "legal_third_parties"
  14. # Fields - Identification
  15. field :code, type: String
  16. field :third_party_type, type: String, default: "provider"
  17. field :person_type, type: String, default: "juridical"
  18. field :status, type: String, default: "active"
  19. # Identification documents
  20. field :identification_type, type: String, default: "NIT"
  21. field :identification_number, type: String
  22. field :verification_digit, type: String # For NIT
  23. # Business info (juridical)
  24. field :business_name, type: String
  25. field :trade_name, type: String
  26. # Personal info (natural)
  27. field :first_name, type: String
  28. field :last_name, type: String
  29. # Contact
  30. field :email, type: String
  31. field :phone, type: String
  32. field :mobile, type: String
  33. field :website, type: String
  34. # Address
  35. field :address, type: String
  36. field :city, type: String
  37. field :state, type: String
  38. field :postal_code, type: String
  39. field :country, type: String, default: "Colombia"
  40. # Legal representative (for juridical)
  41. field :legal_rep_name, type: String
  42. field :legal_rep_id_type, type: String
  43. field :legal_rep_id_number, type: String
  44. field :legal_rep_id_city, type: String
  45. field :legal_rep_email, type: String
  46. field :legal_rep_phone, type: String
  47. # Banking info
  48. field :bank_name, type: String
  49. field :bank_account_type, type: String # savings, checking
  50. field :bank_account_number, type: String
  51. # Categorization
  52. field :industry, type: String
  53. field :tags, type: Array, default: []
  54. field :notes, type: String
  55. # Tax info
  56. field :tax_regime, type: String # simplified, common, special
  57. field :tax_responsibilities, type: Array, default: []
  58. # Associations
  59. belongs_to :organization, class_name: "Identity::Organization"
  60. belongs_to :created_by, class_name: "Identity::User", optional: true
  61. has_many :contracts, class_name: "Legal::Contract", dependent: :restrict_with_error
  62. # Validations
  63. validates :third_party_type, presence: true
  64. validate :valid_third_party_type
  65. validates :person_type, presence: true, inclusion: { in: PERSON_TYPES }
  66. validates :status, presence: true, inclusion: { in: STATUSES }
  67. validates :identification_type, inclusion: { in: IDENTIFICATION_TYPES }, allow_blank: true
  68. validates :identification_number, presence: true, uniqueness: { scope: :organization_id }
  69. validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  70. # Conditional validations
  71. validates :business_name, presence: true, if: :juridical?
  72. validates :first_name, :last_name, presence: true, if: :natural?
  73. # Indexes
  74. index({ organization_id: 1, status: 1 })
  75. index({ organization_id: 1, third_party_type: 1 })
  76. index({ identification_number: 1, organization_id: 1 }, unique: true)
  77. index({ code: 1 }, unique: true)
  78. index({ email: 1 })
  79. # Callbacks
  80. before_create :generate_code
  81. # Scopes
  82. scope :active, -> { where(status: "active") }
  83. scope :inactive, -> { where(status: "inactive") }
  84. scope :blocked, -> { where(status: "blocked") }
  85. scope :providers, -> { where(third_party_type: "provider") }
  86. scope :clients, -> { where(third_party_type: "client") }
  87. scope :contractors, -> { where(third_party_type: "contractor") }
  88. scope :partners, -> { where(third_party_type: "partner") }
  89. scope :by_type, ->(type) { where(third_party_type: type) }
  90. scope :search, ->(query) {
  91. return all if query.blank?
  92. regex = /#{Regexp.escape(query)}/i
  93. any_of(
  94. { business_name: regex },
  95. { trade_name: regex },
  96. { first_name: regex },
  97. { last_name: regex },
  98. { identification_number: regex },
  99. { email: regex },
  100. { code: regex }
  101. )
  102. }
  103. # Custom validations
  104. def valid_third_party_type
  105. return if third_party_type.blank?
  106. # Check if it's a default type
  107. return if TYPES.include?(third_party_type)
  108. # Check if it's a custom type from ThirdPartyType model
  109. return if organization_id && ThirdPartyType.where(
  110. organization_id: organization_id,
  111. code: third_party_type,
  112. active: true
  113. ).exists?
  114. errors.add(:third_party_type, "is not a valid type")
  115. end
  116. # Instance methods
  117. def display_name
  118. if juridical?
  119. trade_name.presence || business_name
  120. else
  121. "#{first_name} #{last_name}".strip
  122. end
  123. end
  124. def full_name
  125. display_name
  126. end
  127. def juridical?
  128. person_type == "juridical"
  129. end
  130. def natural?
  131. person_type == "natural"
  132. end
  133. def active?
  134. status == "active"
  135. end
  136. def inactive?
  137. status == "inactive"
  138. end
  139. def blocked?
  140. status == "blocked"
  141. end
  142. def full_identification
  143. "#{identification_type} #{identification_number}#{verification_digit.present? ? "-#{verification_digit}" : ""}"
  144. end
  145. def full_address
  146. [address, city, state, country].compact.join(", ")
  147. end
  148. def type_label
  149. I18n.t("legal.third_party.types.#{third_party_type}", default: third_party_type.humanize)
  150. end
  151. def status_label
  152. I18n.t("legal.third_party.statuses.#{status}", default: status.humanize)
  153. end
  154. def activate!
  155. update!(status: "active")
  156. end
  157. def deactivate!
  158. update!(status: "inactive")
  159. end
  160. def block!(reason: nil)
  161. update!(status: "blocked", notes: [notes, "Bloqueado: #{reason}"].compact.join("\n"))
  162. end
  163. private
  164. def generate_code
  165. return if code.present?
  166. year = Time.current.year
  167. last_record = self.class
  168. .where(organization_id: organization_id)
  169. .where(:code.ne => nil)
  170. .order(created_at: :desc)
  171. .first
  172. if last_record&.code&.match?(/TER-#{year}-(\d+)/)
  173. last_num = last_record.code.split("-").last.to_i
  174. self.code = "TER-#{year}-#{(last_num + 1).to_s.rjust(5, '0')}"
  175. else
  176. self.code = "TER-#{year}-00001"
  177. end
  178. end
  179. end
  180. end

app/models/legal/third_party_type.rb

0.0% lines covered

47 relevant lines. 0 lines covered and 47 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class ThirdPartyType
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. # Collection
  8. store_in collection: "legal_third_party_types"
  9. # Fields
  10. field :code, type: String # provider, client, etc.
  11. field :name, type: String # Display name
  12. field :description, type: String
  13. field :color, type: String, default: "gray" # For UI badges
  14. field :icon, type: String, default: "building"
  15. field :active, type: Boolean, default: true
  16. field :is_system, type: Boolean, default: false # System types cannot be deleted
  17. field :position, type: Integer, default: 0
  18. # Associations
  19. belongs_to :organization, class_name: "Identity::Organization"
  20. # Validations
  21. validates :code, presence: true, uniqueness: { scope: :organization_id }
  22. validates :name, presence: true
  23. # Indexes
  24. index({ organization_id: 1, active: 1 })
  25. index({ organization_id: 1, code: 1 }, unique: true)
  26. index({ position: 1 })
  27. # Scopes
  28. scope :active, -> { where(active: true) }
  29. scope :ordered, -> { order(position: :asc, name: :asc) }
  30. # Class methods
  31. def self.seed_defaults(organization)
  32. defaults = [
  33. { code: "provider", name: "Proveedor", description: "Proveedores de bienes y servicios", color: "blue", icon: "truck", position: 1 },
  34. { code: "client", name: "Cliente", description: "Clientes de la organización", color: "green", icon: "users", position: 2 },
  35. { code: "contractor", name: "Contratista", description: "Contratistas independientes", color: "purple", icon: "briefcase", position: 3 },
  36. { code: "partner", name: "Aliado", description: "Aliados estratégicos", color: "orange", icon: "handshake", position: 4 },
  37. { code: "other", name: "Otro", description: "Otros tipos de terceros", color: "gray", icon: "building", position: 5 }
  38. ]
  39. defaults.each do |attrs|
  40. existing = where(organization_id: organization.id, code: attrs[:code]).first
  41. if existing
  42. existing.update(attrs.except(:code).merge(is_system: true))
  43. else
  44. create!(attrs.merge(organization_id: organization.id, is_system: true))
  45. end
  46. end
  47. end
  48. # Instance methods
  49. def deletable?
  50. !is_system && Legal::ThirdParty.where(organization_id: organization_id, third_party_type: code).count.zero?
  51. end
  52. def toggle_active!
  53. update!(active: !active)
  54. end
  55. end
  56. end

app/models/retention/legal_hold.rb

0.0% lines covered

157 relevant lines. 0 lines covered and 157 lines missed.
    
  1. # frozen_string_literal: true
  2. module Retention
  3. # Represents a legal hold placed on a document
  4. # While under legal hold, a document cannot be modified, archived, or deleted
  5. #
  6. # Legal holds are typically placed during litigation, regulatory investigation,
  7. # or audit situations where document preservation is legally required
  8. #
  9. # rubocop:disable Metrics/ClassLength
  10. class LegalHold
  11. include Mongoid::Document
  12. include Mongoid::Timestamps
  13. include UuidIdentifiable
  14. store_in collection: "legal_holds"
  15. # Status constants
  16. STATUS_ACTIVE = "active"
  17. STATUS_RELEASED = "released"
  18. STATUSES = [STATUS_ACTIVE, STATUS_RELEASED].freeze
  19. # Hold types
  20. TYPE_LITIGATION = "litigation"
  21. TYPE_REGULATORY = "regulatory"
  22. TYPE_AUDIT = "audit"
  23. TYPE_INVESTIGATION = "investigation"
  24. TYPE_PRESERVATION = "preservation"
  25. TYPES = [TYPE_LITIGATION, TYPE_REGULATORY, TYPE_AUDIT, TYPE_INVESTIGATION, TYPE_PRESERVATION].freeze
  26. # Fields
  27. field :name, type: String
  28. field :description, type: String
  29. field :hold_type, type: String
  30. field :status, type: String, default: STATUS_ACTIVE
  31. field :reference_number, type: String # Case number, audit ID, etc.
  32. # Dates
  33. field :effective_date, type: Time
  34. field :release_date, type: Time
  35. field :expected_release_date, type: Time
  36. # Release information
  37. field :release_reason, type: String
  38. field :released_by_id, type: BSON::ObjectId
  39. field :released_by_name, type: String
  40. # Custodian information
  41. field :custodian_name, type: String
  42. field :custodian_email, type: String
  43. field :custodian_department, type: String
  44. # Notes and history
  45. field :notes, type: String
  46. field :history, type: Array, default: []
  47. # Indexes
  48. index({ uuid: 1 }, { unique: true })
  49. index({ status: 1 })
  50. index({ reference_number: 1 })
  51. index({ schedule_id: 1, status: 1 })
  52. index({ organization_id: 1, status: 1 })
  53. index({ hold_type: 1 })
  54. # Associations
  55. belongs_to :schedule, class_name: "Retention::RetentionSchedule", inverse_of: :legal_holds
  56. belongs_to :organization, class_name: "Identity::Organization"
  57. belongs_to :placed_by, class_name: "Identity::User"
  58. # Validations
  59. validates :name, presence: true
  60. validates :hold_type, presence: true, inclusion: { in: TYPES }
  61. validates :status, presence: true, inclusion: { in: STATUSES }
  62. validates :effective_date, presence: true
  63. validates :custodian_name, presence: true
  64. # Scopes
  65. scope :active, -> { where(status: STATUS_ACTIVE) }
  66. scope :released, -> { where(status: STATUS_RELEASED) }
  67. scope :by_type, ->(type) { where(hold_type: type) }
  68. scope :for_reference, ->(ref) { where(reference_number: ref) }
  69. # Callbacks
  70. after_create :place_schedule_on_hold
  71. after_save :update_schedule_hold_status, if: :saved_change_to_status?
  72. # Check if hold is active
  73. def active?
  74. status == STATUS_ACTIVE
  75. end
  76. # Check if hold is released
  77. def released?
  78. status == STATUS_RELEASED
  79. end
  80. # Release the hold
  81. # rubocop:disable Naming/PredicateMethod
  82. def release!(actor:, reason:)
  83. return false if released?
  84. self.status = STATUS_RELEASED
  85. self.release_date = Time.current
  86. self.release_reason = reason
  87. self.released_by_id = actor.id
  88. self.released_by_name = actor.full_name
  89. record_history("released", actor, reason)
  90. save!
  91. log_audit_event("legal_hold_released", actor, {
  92. release_reason: reason,
  93. hold_duration_days: hold_duration_days
  94. })
  95. true
  96. end
  97. # rubocop:enable Naming/PredicateMethod
  98. # Extend expected release date
  99. def extend!(new_expected_date:, actor:, reason: nil) # rubocop:disable Naming/PredicateMethod
  100. return false unless active?
  101. old_date = expected_release_date
  102. self.expected_release_date = new_expected_date
  103. record_history("extended", actor, "Extended to #{new_expected_date}. Reason: #{reason}")
  104. save!
  105. log_audit_event("legal_hold_extended", actor, {
  106. old_expected_date: old_date&.iso8601,
  107. new_expected_date: new_expected_date.iso8601,
  108. reason: reason
  109. })
  110. true
  111. end
  112. # Duration of hold in days
  113. def hold_duration_days
  114. end_date = release_date || Time.current
  115. ((end_date - effective_date) / 1.day).ceil
  116. end
  117. # Get the document through schedule
  118. def document
  119. schedule&.document
  120. end
  121. # Human-readable hold type
  122. def hold_type_display
  123. hold_type.to_s.titleize
  124. end
  125. private
  126. def place_schedule_on_hold
  127. schedule.place_on_hold!(reason: "Legal hold: #{name}")
  128. log_audit_event("legal_hold_placed", placed_by, {
  129. hold_type: hold_type,
  130. reference_number: reference_number,
  131. custodian: custodian_name
  132. })
  133. end
  134. def update_schedule_hold_status
  135. return unless released?
  136. # Check if there are other active holds
  137. schedule.release_from_hold! unless schedule.legal_holds.active.exists?(:id.ne => id)
  138. end
  139. def record_history(action, actor, details = nil)
  140. history << {
  141. "action" => action,
  142. "at" => Time.current.iso8601,
  143. "actor_id" => actor&.id&.to_s,
  144. "actor_name" => actor&.full_name,
  145. "details" => details
  146. }.compact
  147. end
  148. def log_audit_event(action, actor, metadata = {})
  149. Audit::AuditEvent.log(
  150. event_type: Audit::AuditEvent::TYPES[:record],
  151. action: action,
  152. target: document || schedule,
  153. actor: actor,
  154. metadata: metadata.merge(
  155. legal_hold_id: id.to_s,
  156. legal_hold_name: name
  157. ),
  158. tags: ["legal_hold", action]
  159. )
  160. end
  161. class << self
  162. # Find all holds for a document
  163. def for_document(document)
  164. schedule = Retention::RetentionSchedule.where(document_id: document.id).first
  165. return none unless schedule
  166. where(schedule_id: schedule.id)
  167. end
  168. # Place a new hold on a document
  169. def place_hold!(document:, name:, hold_type:, placed_by:, organization:, **)
  170. # Find or create retention schedule for document
  171. schedule = Retention::RetentionSchedule.where(document_id: document.id).first
  172. schedule ||= Retention::RetentionSchedule.create!(
  173. document: document,
  174. organization: organization,
  175. status: Retention::RetentionSchedule::STATUS_HELD,
  176. retention_start_date: Time.current
  177. )
  178. create!(
  179. schedule: schedule,
  180. organization: organization,
  181. name: name,
  182. hold_type: hold_type,
  183. placed_by: placed_by,
  184. effective_date: Time.current,
  185. **
  186. )
  187. end
  188. end
  189. end
  190. # rubocop:enable Metrics/ClassLength
  191. end

app/models/retention/retention_policy.rb

0.0% lines covered

149 relevant lines. 0 lines covered and 149 lines missed.
    
  1. # frozen_string_literal: true
  2. module Retention
  3. # Defines retention rules for documents based on type
  4. # Specifies how long documents should be retained before archiving/expiration
  5. #
  6. # Example: Contracts must be retained for 7 years after completion
  7. #
  8. class RetentionPolicy
  9. include Mongoid::Document
  10. include Mongoid::Timestamps
  11. include UuidIdentifiable
  12. store_in collection: "retention_policies"
  13. # Retention action types
  14. ACTION_ARCHIVE = "archive"
  15. ACTION_EXPIRE = "expire"
  16. ACTION_REVIEW = "review"
  17. ACTION_DESTROY = "destroy"
  18. ACTIONS = [ACTION_ARCHIVE, ACTION_EXPIRE, ACTION_REVIEW, ACTION_DESTROY].freeze
  19. # Trigger types - when to start counting retention period
  20. TRIGGER_CREATION = "creation"
  21. TRIGGER_LAST_MODIFIED = "last_modified"
  22. TRIGGER_WORKFLOW_COMPLETE = "workflow_complete"
  23. TRIGGER_CUSTOM_DATE = "custom_date"
  24. TRIGGERS = [TRIGGER_CREATION, TRIGGER_LAST_MODIFIED, TRIGGER_WORKFLOW_COMPLETE, TRIGGER_CUSTOM_DATE].freeze
  25. # Fields
  26. field :name, type: String
  27. field :description, type: String
  28. field :document_type, type: String # Type of document this policy applies to
  29. field :active, type: Boolean, default: true
  30. # Retention period configuration
  31. field :retention_period_days, type: Integer
  32. field :retention_trigger, type: String, default: TRIGGER_CREATION
  33. # Action to take when retention period expires
  34. field :expiration_action, type: String, default: ACTION_ARCHIVE
  35. # Warning period - days before expiration to send warnings
  36. field :warning_days, type: Integer, default: 30
  37. # Priority for policy selection (higher = more specific)
  38. field :priority, type: Integer, default: 0
  39. # Custom field for trigger date (if using custom_date trigger)
  40. field :custom_trigger_field, type: String
  41. # Indexes
  42. index({ uuid: 1 }, { unique: true })
  43. index({ document_type: 1, active: 1 })
  44. index({ organization_id: 1, active: 1 })
  45. index({ priority: -1 })
  46. # Associations
  47. belongs_to :organization, class_name: "Identity::Organization", optional: true
  48. has_many :schedules, class_name: "Retention::RetentionSchedule", inverse_of: :policy
  49. # Validations
  50. validates :name, presence: true
  51. validates :document_type, presence: true
  52. validates :retention_period_days, presence: true, numericality: { greater_than: 0 }
  53. validates :retention_trigger, presence: true, inclusion: { in: TRIGGERS }
  54. validates :expiration_action, presence: true, inclusion: { in: ACTIONS }
  55. validates :warning_days, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
  56. # Scopes
  57. scope :active, -> { where(active: true) }
  58. scope :for_document_type, ->(type) { where(document_type: type) }
  59. scope :by_priority, -> { order(priority: :desc) }
  60. scope :global, -> { where(organization_id: nil) }
  61. # Calculate expiration date based on document
  62. def calculate_expiration_date(document)
  63. trigger_date = determine_trigger_date(document)
  64. return nil unless trigger_date
  65. trigger_date + retention_period_days.days
  66. end
  67. # Calculate warning date
  68. def calculate_warning_date(document)
  69. expiration = calculate_expiration_date(document)
  70. return nil unless expiration && warning_days&.positive?
  71. expiration - warning_days.days
  72. end
  73. # Human-readable retention period
  74. def retention_period_text
  75. if retention_period_days >= 365
  76. years = retention_period_days / 365
  77. "#{years} year#{"s" unless years == 1}"
  78. elsif retention_period_days >= 30
  79. months = retention_period_days / 30
  80. "#{months} month#{"s" unless months == 1}"
  81. else
  82. "#{retention_period_days} day#{"s" unless retention_period_days == 1}"
  83. end
  84. end
  85. private
  86. def determine_trigger_date(document)
  87. case retention_trigger
  88. when TRIGGER_CREATION
  89. document.created_at
  90. when TRIGGER_LAST_MODIFIED
  91. document.updated_at
  92. when TRIGGER_WORKFLOW_COMPLETE
  93. find_workflow_completion_date(document)
  94. when TRIGGER_CUSTOM_DATE
  95. document.metadata&.dig(custom_trigger_field)&.to_time
  96. end
  97. end
  98. def find_workflow_completion_date(document)
  99. # Find the most recent completed workflow for this document
  100. workflow = Workflow::WorkflowInstance
  101. .where(document_id: document.id, status: Workflow::WorkflowInstance::STATUS_COMPLETED)
  102. .order(completed_at: :desc)
  103. .first
  104. workflow&.completed_at
  105. end
  106. class << self
  107. # Find the best matching policy for a document
  108. def find_policy_for(document, organization: nil)
  109. # First try organization-specific policies
  110. if organization
  111. policy = active.for_document_type(document.document_type)
  112. .where(organization_id: organization.id)
  113. .by_priority
  114. .first
  115. return policy if policy
  116. end
  117. # Fall back to global policies
  118. active.for_document_type(document.document_type)
  119. .global
  120. .by_priority
  121. .first
  122. end
  123. # Seed default retention policies
  124. # rubocop:disable Metrics/MethodLength
  125. def seed_defaults!
  126. policies = [
  127. {
  128. name: "Contract Retention",
  129. description: "Contracts must be retained for 7 years after workflow completion",
  130. document_type: "contract",
  131. retention_period_days: 7 * 365, # 7 years
  132. retention_trigger: TRIGGER_WORKFLOW_COMPLETE,
  133. expiration_action: ACTION_ARCHIVE,
  134. warning_days: 90,
  135. priority: 10
  136. },
  137. {
  138. name: "Invoice Retention",
  139. description: "Invoices must be retained for 5 years from creation",
  140. document_type: "invoice",
  141. retention_period_days: 5 * 365, # 5 years
  142. retention_trigger: TRIGGER_CREATION,
  143. expiration_action: ACTION_ARCHIVE,
  144. warning_days: 60,
  145. priority: 10
  146. },
  147. {
  148. name: "HR Document Retention",
  149. description: "HR documents retained for 7 years after last modification",
  150. document_type: "hr_document",
  151. retention_period_days: 7 * 365,
  152. retention_trigger: TRIGGER_LAST_MODIFIED,
  153. expiration_action: ACTION_REVIEW,
  154. warning_days: 90,
  155. priority: 10
  156. },
  157. {
  158. name: "General Document Retention",
  159. description: "Default retention policy for general documents",
  160. document_type: "general",
  161. retention_period_days: 3 * 365, # 3 years
  162. retention_trigger: TRIGGER_CREATION,
  163. expiration_action: ACTION_ARCHIVE,
  164. warning_days: 30,
  165. priority: 0
  166. }
  167. ]
  168. policies.each do |attrs|
  169. find_or_create_by!(name: attrs[:name]) do |p|
  170. p.assign_attributes(attrs)
  171. end
  172. end
  173. end
  174. # rubocop:enable Metrics/MethodLength
  175. end
  176. end
  177. end

app/models/retention/retention_schedule.rb

0.0% lines covered

201 relevant lines. 0 lines covered and 201 lines missed.
    
  1. # frozen_string_literal: true
  2. module Retention
  3. # Tracks retention status for individual documents
  4. # Links a document to its applicable policy and tracks lifecycle events
  5. #
  6. # Note: Documents are NEVER physically deleted - they are marked for archive/expiration
  7. #
  8. # rubocop:disable Metrics/ClassLength
  9. class RetentionSchedule
  10. include Mongoid::Document
  11. include Mongoid::Timestamps
  12. include UuidIdentifiable
  13. store_in collection: "retention_schedules"
  14. # Status constants
  15. STATUS_ACTIVE = "active" # Document within retention period
  16. STATUS_WARNING = "warning" # Approaching expiration
  17. STATUS_PENDING_ACTION = "pending" # Ready for expiration action
  18. STATUS_ARCHIVED = "archived" # Archived (still accessible)
  19. STATUS_EXPIRED = "expired" # Expired but preserved
  20. STATUS_HELD = "held" # Under legal hold
  21. STATUSES = [
  22. STATUS_ACTIVE, STATUS_WARNING, STATUS_PENDING_ACTION,
  23. STATUS_ARCHIVED, STATUS_EXPIRED, STATUS_HELD
  24. ].freeze
  25. # Fields
  26. field :status, type: String, default: STATUS_ACTIVE
  27. field :retention_start_date, type: Time
  28. field :expiration_date, type: Time
  29. field :warning_date, type: Time
  30. field :action_date, type: Time # When the action was taken
  31. field :action_taken, type: String # What action was performed
  32. # Tracking fields
  33. field :warning_sent_at, type: Time
  34. field :warning_count, type: Integer, default: 0
  35. field :last_reviewed_at, type: Time
  36. field :reviewed_by_id, type: BSON::ObjectId
  37. # Notes and history
  38. field :notes, type: String
  39. field :history, type: Array, default: []
  40. # Indexes
  41. index({ uuid: 1 }, { unique: true })
  42. index({ document_id: 1 }, { unique: true })
  43. index({ status: 1 })
  44. index({ expiration_date: 1 })
  45. index({ warning_date: 1 })
  46. index({ organization_id: 1, status: 1 })
  47. # Associations
  48. belongs_to :document, class_name: "Content::Document"
  49. belongs_to :policy, class_name: "Retention::RetentionPolicy", optional: true
  50. belongs_to :organization, class_name: "Identity::Organization"
  51. has_many :legal_holds, class_name: "Retention::LegalHold", inverse_of: :schedule
  52. # Validations
  53. validates :status, presence: true, inclusion: { in: STATUSES }
  54. validates :document_id, uniqueness: true
  55. # Scopes
  56. scope :active, -> { where(status: STATUS_ACTIVE) }
  57. scope :warning, -> { where(status: STATUS_WARNING) }
  58. scope :pending_action, -> { where(status: STATUS_PENDING_ACTION) }
  59. scope :archived, -> { where(status: STATUS_ARCHIVED) }
  60. scope :expired, -> { where(status: STATUS_EXPIRED) }
  61. scope :held, -> { where(status: STATUS_HELD) }
  62. scope :expiring_soon, lambda { |days = 30|
  63. where(:expiration_date.lte => Time.current + days.days)
  64. .where(:status.in => [STATUS_ACTIVE, STATUS_WARNING])
  65. }
  66. scope :past_expiration, lambda {
  67. where(:expiration_date.lte => Time.current)
  68. .where(:status.in => [STATUS_ACTIVE, STATUS_WARNING, STATUS_PENDING_ACTION])
  69. }
  70. scope :needs_warning, lambda {
  71. where(:warning_date.lte => Time.current)
  72. .where(status: STATUS_ACTIVE)
  73. }
  74. # Check if document is under legal hold
  75. def under_legal_hold?
  76. legal_holds.active.exists?
  77. end
  78. # Check if document can be modified
  79. def modification_allowed?
  80. !under_legal_hold? && !archived? && !expired?
  81. end
  82. # Check if document can be deleted (spoiler: never physically deleted)
  83. def deletion_allowed?
  84. false # Documents are NEVER physically deleted
  85. end
  86. # Status checks
  87. def active?
  88. status == STATUS_ACTIVE
  89. end
  90. def archived?
  91. status == STATUS_ARCHIVED
  92. end
  93. def expired?
  94. status == STATUS_EXPIRED
  95. end
  96. def held?
  97. status == STATUS_HELD
  98. end
  99. def past_expiration?
  100. expiration_date.present? && Time.current > expiration_date
  101. end
  102. def needs_warning?
  103. warning_date.present? && Time.current >= warning_date && active?
  104. end
  105. # Transition to warning status
  106. def mark_warning!(actor: nil)
  107. return if under_legal_hold?
  108. return unless active?
  109. self.status = STATUS_WARNING
  110. self.warning_sent_at = Time.current
  111. self.warning_count += 1
  112. record_history("warning_sent", actor)
  113. save!
  114. self
  115. end
  116. # Mark for pending action (ready for archive/expire)
  117. def mark_pending!(actor: nil)
  118. return if under_legal_hold?
  119. self.status = STATUS_PENDING_ACTION
  120. record_history("marked_pending", actor)
  121. save!
  122. self
  123. end
  124. # Archive the document (soft action - document still accessible)
  125. def archive!(actor:, notes: nil) # rubocop:disable Naming/PredicateMethod
  126. return false if under_legal_hold?
  127. self.status = STATUS_ARCHIVED
  128. self.action_date = Time.current
  129. self.action_taken = RetentionPolicy::ACTION_ARCHIVE
  130. self.notes = notes if notes
  131. # Update document status
  132. document.update!(retention_status: "archived")
  133. record_history("archived", actor, notes)
  134. save!
  135. log_audit_event("document_archived", actor)
  136. true
  137. end
  138. # Mark as expired (document preserved but flagged)
  139. def expire!(actor:, notes: nil) # rubocop:disable Naming/PredicateMethod
  140. return false if under_legal_hold?
  141. self.status = STATUS_EXPIRED
  142. self.action_date = Time.current
  143. self.action_taken = RetentionPolicy::ACTION_EXPIRE
  144. self.notes = notes if notes
  145. # Update document status
  146. document.update!(retention_status: "expired")
  147. record_history("expired", actor, notes)
  148. save!
  149. log_audit_event("document_expired", actor)
  150. true
  151. end
  152. # Place under legal hold
  153. def place_on_hold!(reason:)
  154. self.status = STATUS_HELD
  155. record_history("placed_on_hold", nil, reason)
  156. save!
  157. self
  158. end
  159. # Release from legal hold (if no other holds exist)
  160. def release_from_hold!
  161. return if legal_holds.active.exists?
  162. # Determine appropriate status
  163. self.status = if past_expiration?
  164. STATUS_PENDING_ACTION
  165. elsif needs_warning?
  166. STATUS_WARNING
  167. else
  168. STATUS_ACTIVE
  169. end
  170. record_history("released_from_hold", nil)
  171. save!
  172. self
  173. end
  174. # Extend retention period
  175. def extend_retention!(additional_days:, actor:, reason: nil) # rubocop:disable Naming/PredicateMethod
  176. return false if under_legal_hold?
  177. old_date = expiration_date
  178. self.expiration_date = expiration_date + additional_days.days
  179. self.warning_date = policy.calculate_warning_date(document) if policy.warning_days&.positive?
  180. # Reset status if was pending
  181. self.status = STATUS_ACTIVE if status == STATUS_PENDING_ACTION
  182. record_history("retention_extended", actor, "Extended by #{additional_days} days. Reason: #{reason}")
  183. save!
  184. log_audit_event("retention_extended", actor, {
  185. old_expiration: old_date&.iso8601,
  186. new_expiration: expiration_date.iso8601,
  187. additional_days: additional_days,
  188. reason: reason
  189. })
  190. true
  191. end
  192. # Record review
  193. def record_review!(actor:, notes: nil)
  194. self.last_reviewed_at = Time.current
  195. self.reviewed_by_id = actor.id
  196. record_history("reviewed", actor, notes)
  197. save!
  198. self
  199. end
  200. # Days until expiration
  201. def days_until_expiration
  202. return nil unless expiration_date
  203. ((expiration_date - Time.current) / 1.day).ceil
  204. end
  205. # Days overdue
  206. def days_overdue
  207. return 0 unless past_expiration?
  208. ((Time.current - expiration_date) / 1.day).floor
  209. end
  210. private
  211. def record_history(action, actor = nil, details = nil)
  212. history << {
  213. "action" => action,
  214. "at" => Time.current.iso8601,
  215. "actor_id" => actor&.id&.to_s,
  216. "actor_name" => actor&.full_name,
  217. "details" => details
  218. }.compact
  219. end
  220. def log_audit_event(action, actor, metadata = {})
  221. Audit::AuditEvent.log(
  222. event_type: Audit::AuditEvent::TYPES[:record],
  223. action: action,
  224. target: document,
  225. actor: actor,
  226. metadata: metadata.merge(
  227. retention_policy: policy.name,
  228. schedule_id: id.to_s
  229. ),
  230. tags: ["retention", action]
  231. )
  232. end
  233. end
  234. # rubocop:enable Metrics/ClassLength
  235. end

app/models/templates/generated_document.rb

0.0% lines covered

405 relevant lines. 0 lines covered and 405 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class GeneratedDocument
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. store_in collection: "generated_documents"
  8. # Status values
  9. DRAFT = "draft"
  10. PENDING_SIGNATURES = "pending_signatures"
  11. COMPLETED = "completed"
  12. CANCELLED = "cancelled"
  13. STATUSES = [DRAFT, PENDING_SIGNATURES, COMPLETED, CANCELLED].freeze
  14. # Fields
  15. field :name, type: String
  16. field :status, type: String, default: DRAFT
  17. # PDF file storage (GridFS)
  18. field :draft_file_id, type: BSON::ObjectId # Current working PDF (may have signatures)
  19. field :original_draft_file_id, type: BSON::ObjectId # Original PDF without any signatures
  20. field :final_file_id, type: BSON::ObjectId # PDF with all signatures
  21. field :docx_file_id, type: BSON::ObjectId # Source DOCX for local PDF generation
  22. field :file_name, type: String
  23. # PDF generation tracking (for local sync workflow)
  24. field :pdf_generation_status, type: String, default: "completed"
  25. # Values: "completed", "pending", "failed"
  26. # Variable values used for generation
  27. field :variable_values, type: Hash, default: {}
  28. # Reference to the source request (certification, vacation, etc.)
  29. field :source_type, type: String # "Hr::EmploymentCertificationRequest", "Hr::VacationRequest"
  30. field :source_id, type: BSON::ObjectId
  31. # Signature tracking
  32. field :signatures, type: Array, default: []
  33. # Each signature entry: { signatory_id, user_id, signed_at, signature_id, status }
  34. field :completed_at, type: Time
  35. field :expires_at, type: Time
  36. # Direct employee reference (for documents generated directly for an employee)
  37. field :employee_id, type: BSON::ObjectId
  38. # Associations
  39. belongs_to :template, class_name: "Templates::Template", optional: true
  40. belongs_to :organization, class_name: "Identity::Organization"
  41. belongs_to :requested_by, class_name: "Identity::User"
  42. belongs_to :employee, class_name: "Hr::Employee", optional: true
  43. # Indexes
  44. index({ organization_id: 1 })
  45. index({ template_id: 1 })
  46. index({ status: 1 })
  47. index({ source_type: 1, source_id: 1 })
  48. index({ requested_by_id: 1 })
  49. index({ employee_id: 1 })
  50. index({ created_at: -1 })
  51. # Validations
  52. validates :name, presence: true, length: { maximum: 255 }
  53. validates :status, presence: true, inclusion: { in: STATUSES }
  54. # Scopes
  55. scope :draft, -> { where(status: DRAFT) }
  56. scope :pending_signatures, -> { where(status: PENDING_SIGNATURES) }
  57. scope :completed, -> { where(status: COMPLETED) }
  58. scope :cancelled, -> { where(status: CANCELLED) }
  59. scope :for_user, ->(user) { where(requested_by_id: user.id) }
  60. scope :pending_pdf_generation, -> { where(pdf_generation_status: "pending") }
  61. scope :pending_signature_by, lambda { |user|
  62. where(
  63. status: PENDING_SIGNATURES,
  64. "signatures.user_id" => user.id.to_s,
  65. "signatures.status" => "pending"
  66. )
  67. }
  68. # Instance methods
  69. def draft?
  70. status == DRAFT
  71. end
  72. def pending_signatures?
  73. status == PENDING_SIGNATURES
  74. end
  75. def completed?
  76. status == COMPLETED
  77. end
  78. def cancelled?
  79. status == CANCELLED
  80. end
  81. def source
  82. return nil unless source_type && source_id
  83. source_type.constantize.find(source_id)
  84. rescue Mongoid::Errors::DocumentNotFound
  85. nil
  86. end
  87. def source=(record)
  88. return if record.nil?
  89. self.source_type = record.class.name
  90. self.source_id = record.id
  91. end
  92. # Initialize signature tracking from template signatories
  93. def initialize_signatures!
  94. return unless template
  95. self.signatures = template.signatories.by_position.map do |sig|
  96. user = sig.find_signatory_for(signature_context)
  97. {
  98. "signatory_id" => sig.uuid,
  99. "signatory_type_code" => sig.signatory_type_code,
  100. "signatory_role" => sig.role,
  101. "signatory_label" => sig.label,
  102. "label" => sig.label,
  103. "user_id" => user&.id&.to_s,
  104. "user_name" => user&.full_name,
  105. "required" => sig.required,
  106. "status" => "pending",
  107. "signature_id" => nil,
  108. "signed_at" => nil,
  109. "signed_by_name" => nil
  110. }
  111. end
  112. update!(status: PENDING_SIGNATURES) if signatures.any?
  113. end
  114. # Apply a user's signature
  115. # custom_position: { x:, y:, width:, height: } - optional override for signature position
  116. def sign!(user:, signature:, custom_position: nil)
  117. sig_entry = find_pending_signature_for(user)
  118. raise SignatureError, "No hay firma pendiente para este usuario" unless sig_entry
  119. raise SignatureError, "Usuario no tiene firma digital configurada" unless signature
  120. # Check signature order if sequential signing is enabled
  121. unless can_sign_at_position?(sig_entry)
  122. blocking = blocking_signatures_for(sig_entry)
  123. waiting_names = blocking.map { |b| b["signatory_label"] || b["label"] }.join(", ")
  124. raise SignatureError, "Debe esperar las firmas de: #{waiting_names}"
  125. end
  126. sig_entry["signature_id"] = signature.uuid
  127. sig_entry["signed_at"] = Time.current.iso8601
  128. sig_entry["signed_by_name"] = user.full_name
  129. sig_entry["status"] = "signed"
  130. # Store custom position if provided
  131. if custom_position.present?
  132. sig_entry["custom_x"] = custom_position[:x] if custom_position[:x]
  133. sig_entry["custom_y"] = custom_position[:y] if custom_position[:y]
  134. sig_entry["custom_width"] = custom_position[:width] if custom_position[:width]
  135. sig_entry["custom_height"] = custom_position[:height] if custom_position[:height]
  136. end
  137. save!
  138. # Apply signature to PDF immediately (don't wait for all signatures)
  139. apply_signature_to_pdf!(sig_entry, signature)
  140. # Check if all required signatures are complete
  141. check_completion!
  142. end
  143. # Apply a single signature to the current PDF
  144. def apply_signature_to_pdf!(sig_entry, signature)
  145. signatory_uuid = sig_entry["signatory_id"]
  146. signatory = signatory_uuid.present? ? template&.signatories&.where(uuid: signatory_uuid)&.first : nil
  147. return unless signatory
  148. pdf_content = file_content
  149. return unless pdf_content
  150. # Create working files
  151. input_pdf = Tempfile.new(["input", ".pdf"])
  152. input_pdf.binmode
  153. input_pdf.write(pdf_content)
  154. input_pdf.rewind
  155. begin
  156. # Load the PDF
  157. pdf = CombinePDF.load(input_pdf.path)
  158. # Get page dimensions to calculate correct page from absolute Y
  159. first_page = pdf.pages.first
  160. page_height = first_page.mediabox[3].to_f
  161. # Calculate which page the signature should go on based on absolute Y
  162. absolute_y = signatory.y_position.to_f
  163. calculated_page = (absolute_y / page_height).floor + 1
  164. relative_y = absolute_y % page_height
  165. # Use calculated page, but respect explicit page_number if Y is within first page
  166. page_index = if absolute_y < page_height && signatory.page_number
  167. (signatory.page_number || 1) - 1
  168. else
  169. calculated_page - 1
  170. end
  171. page_index = [[page_index, 0].max, pdf.pages.count - 1].min
  172. target_page = pdf.pages[page_index]
  173. Rails.logger.info "Signature placement: absoluteY=#{absolute_y}, pageHeight=#{page_height}, calculatedPage=#{calculated_page}, relativeY=#{relative_y}, pageIndex=#{page_index}"
  174. # Get signature image
  175. renderer = Templates::SignatureRendererService.new(signature)
  176. img_tempfile = renderer.to_tempfile
  177. begin
  178. # Create signature overlay with relative Y position for this page
  179. overlay_pdf = create_signature_overlay_for(
  180. img_path: img_tempfile.path,
  181. signatory: signatory,
  182. sig_entry: sig_entry,
  183. page_width: target_page.mediabox[2],
  184. page_height: target_page.mediabox[3],
  185. relative_y: relative_y
  186. )
  187. # Merge overlay onto page
  188. overlay = CombinePDF.parse(overlay_pdf)
  189. target_page << overlay.pages.first
  190. # Save updated PDF
  191. output_pdf = Tempfile.new(["updated", ".pdf"])
  192. pdf.save(output_pdf.path)
  193. # Update in GridFS (replace draft with signed version)
  194. store_updated_pdf(File.binread(output_pdf.path))
  195. ensure
  196. img_tempfile.close
  197. img_tempfile.unlink
  198. output_pdf&.close
  199. output_pdf&.unlink
  200. end
  201. ensure
  202. input_pdf.close
  203. input_pdf.unlink
  204. end
  205. rescue StandardError => e
  206. Rails.logger.error("Error applying signature to PDF: #{e.message}")
  207. Rails.logger.error(e.backtrace.first(5).join("\n"))
  208. end
  209. def create_signature_overlay_for(img_path:, signatory:, sig_entry:, page_width:, page_height:, relative_y: nil)
  210. box = signatory.signature_box
  211. # Use custom position from sig_entry if available, otherwise use template defaults
  212. x = sig_entry["custom_x"] || box[:x]
  213. base_y = sig_entry["custom_y"] || box[:y]
  214. # Use relative_y if provided (for multi-page documents), otherwise use base_y
  215. y = relative_y || base_y
  216. width = sig_entry["custom_width"] || box[:width]
  217. height = sig_entry["custom_height"] || box[:height]
  218. date_position = box[:date_position] || "right"
  219. show_label = box[:show_label].nil? ? true : box[:show_label]
  220. show_signer_name = box[:show_signer_name] || false
  221. pdf = Prawn::Document.new(
  222. page_size: [page_width, page_height],
  223. margin: 0
  224. )
  225. # Calculate text space needed below signature
  226. text_lines = 0
  227. text_lines += 1 if show_label
  228. text_lines += 1 if show_signer_name
  229. text_space = text_lines * 10
  230. # Calculate signature dimensions based on date position
  231. # This ensures the preview matches what's rendered
  232. sig_width, sig_height, sig_y_offset = case date_position
  233. when "right"
  234. # Fecha a la derecha: firma usa 75% del ancho
  235. [width * 0.75, height - text_space, 0]
  236. when "below"
  237. # Fecha debajo: firma usa 100% ancho, 80% alto, fecha en el 20% inferior
  238. [width, (height - text_space) * 0.80, (height - text_space) * 0.20]
  239. when "above"
  240. # Fecha arriba: firma usa 100% ancho, 80% alto, firma en el 80% inferior
  241. [width, (height - text_space) * 0.80, 0]
  242. when "none"
  243. # Sin fecha: firma usa 100% del espacio
  244. [width, height - text_space, 0]
  245. else
  246. [width * 0.75, height - text_space, 0]
  247. end
  248. # Calculate position from bottom (Prawn uses bottom-left origin)
  249. # y is distance from TOP of page to TOP of signature box
  250. # Prawn's image at: [x, y] positions the TOP-LEFT of the image at (x, y) from bottom-left origin
  251. # So we need: y_position_from_bottom = page_height - y_from_top
  252. sig_top_from_bottom = page_height - y + sig_y_offset
  253. Rails.logger.info "Signature overlay: x=#{x}, y=#{y}, sig_top_from_bottom=#{sig_top_from_bottom}, page_height=#{page_height}, date_position=#{date_position}, show_label=#{show_label}"
  254. # Draw signature image - fit maintains aspect ratio within the specified dimensions
  255. # at: positions TOP-LEFT corner of image at given coordinates
  256. pdf.image img_path, at: [x, sig_top_from_bottom], fit: [sig_width, sig_height]
  257. # Add optional label and signer name below signature
  258. # Position text below the signature (signature bottom = sig_top - sig_height)
  259. current_y = sig_top_from_bottom - sig_height - 3
  260. if show_label
  261. pdf.fill_color "333333"
  262. pdf.draw_text signatory.label, at: [x, current_y], size: 7
  263. pdf.fill_color "000000"
  264. current_y -= 10
  265. end
  266. if show_signer_name && sig_entry["signed_by_name"].present?
  267. pdf.fill_color "666666"
  268. pdf.draw_text sig_entry["signed_by_name"], at: [x, current_y], size: 6
  269. pdf.fill_color "000000"
  270. end
  271. # Add date based on position setting
  272. unless date_position == "none"
  273. pdf.fill_color "666666"
  274. signed_at = sig_entry["signed_at"]
  275. date_str = signed_at ? Time.parse(signed_at).strftime("%d/%m/%Y") : ""
  276. time_str = signed_at ? Time.parse(signed_at).strftime("%H:%M") : ""
  277. # Calculate signature center Y for positioning date
  278. sig_center_y = sig_top_from_bottom - (sig_height / 2)
  279. case date_position
  280. when "right"
  281. # Fecha a la derecha de la firma (vertical, centrada)
  282. date_x = x + sig_width + 5
  283. pdf.draw_text date_str, at: [date_x, sig_center_y + 5], size: 7
  284. pdf.draw_text time_str, at: [date_x, sig_center_y - 7], size: 6
  285. when "below"
  286. # Fecha debajo de la firma (horizontal, centrada)
  287. date_text = "#{date_str} #{time_str}"
  288. date_x = x + (width / 2) - 25
  289. date_y = sig_top_from_bottom - sig_height - 15
  290. pdf.draw_text date_text, at: [date_x, date_y], size: 7
  291. when "above"
  292. # Fecha arriba de la firma (horizontal, centrada)
  293. date_text = "#{date_str} #{time_str}"
  294. date_x = x + (width / 2) - 25
  295. date_y = sig_top_from_bottom + 5
  296. pdf.draw_text date_text, at: [date_x, date_y], size: 7
  297. end
  298. pdf.fill_color "000000"
  299. end
  300. pdf.render
  301. end
  302. def store_updated_pdf(pdf_content)
  303. file_name = file_name_base + "-signed.pdf"
  304. pdf_file = Mongoid::GridFs.put(
  305. StringIO.new(pdf_content),
  306. filename: file_name,
  307. content_type: "application/pdf"
  308. )
  309. # Keep as draft_file_id to maintain the workflow
  310. # Delete old file if exists
  311. Mongoid::GridFs.delete(draft_file_id) if draft_file_id
  312. update!(draft_file_id: pdf_file.id)
  313. end
  314. def file_name_base
  315. file_name&.gsub(/\.pdf$/i, "") || "document"
  316. end
  317. def pending_signatures_count
  318. signatures.count { |s| s["status"] == "pending" && s["required"] }
  319. end
  320. def completed_signatures_count
  321. signatures.count { |s| s["status"] == "signed" }
  322. end
  323. def total_required_signatures
  324. signatures.count { |s| s["required"] }
  325. end
  326. def all_required_signed?
  327. signatures.select { |s| s["required"] }.all? { |s| s["status"] == "signed" }
  328. end
  329. def can_be_signed_by?(user)
  330. signatures.any? do |s|
  331. s["user_id"] == user.id.to_s && s["status"] == "pending" && can_sign_at_position?(s)
  332. end
  333. end
  334. # Check if sequential signing is enabled for this document's template
  335. def sequential_signing?
  336. template&.sequential_signing != false
  337. end
  338. # Check if a signature at a given position can be signed now
  339. # (all previous required signatures must be completed)
  340. def can_sign_at_position?(sig_entry)
  341. return true unless sequential_signing?
  342. sig_index = signatures.index(sig_entry)
  343. return true if sig_index.nil? || sig_index.zero?
  344. # Check all previous required signatures are signed
  345. signatures[0...sig_index].all? do |prev_sig|
  346. !prev_sig["required"] || prev_sig["status"] == "signed"
  347. end
  348. end
  349. # Get the signatures that are blocking a given signature
  350. def blocking_signatures_for(sig_entry)
  351. return [] unless sequential_signing?
  352. sig_index = signatures.index(sig_entry)
  353. return [] if sig_index.nil? || sig_index.zero?
  354. # Return all previous required signatures that are not signed
  355. signatures[0...sig_index].select do |prev_sig|
  356. prev_sig["required"] && prev_sig["status"] != "signed"
  357. end
  358. end
  359. # Get signature status with order information
  360. def signature_with_order_status(sig_entry)
  361. can_sign = can_sign_at_position?(sig_entry)
  362. blocking = blocking_signatures_for(sig_entry)
  363. {
  364. can_sign_now: can_sign,
  365. waiting_for: blocking.map { |b| b["signatory_label"] || b["label"] },
  366. waiting_count: blocking.count
  367. }
  368. end
  369. # Get next signatory who can sign
  370. def next_signatory_to_sign
  371. return nil unless pending_signatures?
  372. pending_signatories.find { |sig| can_sign_at_position?(sig) }
  373. end
  374. def pending_signatories
  375. signatures.select { |s| s["status"] == "pending" }
  376. end
  377. def signed_signatories
  378. signatures.select { |s| s["status"] == "signed" }
  379. end
  380. # Get the current file (final if completed, draft otherwise)
  381. def current_file_id
  382. completed? && final_file_id ? final_file_id : draft_file_id
  383. end
  384. def file_content
  385. file_id = current_file_id
  386. return nil unless file_id
  387. file = Mongoid::GridFs.get(file_id)
  388. file.data
  389. rescue StandardError => e
  390. Rails.logger.error "Error reading generated document from GridFS: #{e.message}"
  391. nil
  392. end
  393. def docx_content
  394. return nil unless docx_file_id
  395. file = Mongoid::GridFs.get(docx_file_id)
  396. file.data
  397. rescue StandardError => e
  398. Rails.logger.error "Error reading DOCX from GridFS: #{e.message}"
  399. nil
  400. end
  401. def pending_pdf?
  402. pdf_generation_status == "pending"
  403. end
  404. def store_pdf_from_sync!(pdf_content)
  405. file_name = "#{name.parameterize}.pdf"
  406. pdf_file = Mongoid::GridFs.put(
  407. StringIO.new(pdf_content),
  408. filename: file_name,
  409. content_type: "application/pdf"
  410. )
  411. # Store as both draft and original (original never gets modified)
  412. update!(
  413. draft_file_id: pdf_file.id,
  414. original_draft_file_id: pdf_file.id,
  415. pdf_generation_status: "completed"
  416. )
  417. # Initialize signatures now that we have a PDF
  418. initialize_signatures!
  419. end
  420. # Reset signatures and restore original PDF without any signatures
  421. def reset_signatures!
  422. # Restore original PDF if available
  423. if original_draft_file_id.present?
  424. # Copy original to draft (don't modify original)
  425. original_content = Mongoid::GridFs.get(original_draft_file_id).data
  426. new_draft = Mongoid::GridFs.put(
  427. StringIO.new(original_content),
  428. filename: file_name || "document.pdf",
  429. content_type: "application/pdf"
  430. )
  431. self.draft_file_id = new_draft.id
  432. end
  433. # Reset all signatures to pending
  434. signatures.each do |s|
  435. s["status"] = "pending"
  436. s["signature_id"] = nil
  437. s["signed_at"] = nil
  438. s["signed_by_name"] = nil
  439. end
  440. self.status = PENDING_SIGNATURES
  441. self.final_file_id = nil
  442. self.completed_at = nil
  443. save!
  444. end
  445. def cancel!(reason: nil)
  446. update!(
  447. status: CANCELLED,
  448. variable_values: variable_values.merge("cancellation_reason" => reason)
  449. )
  450. end
  451. private
  452. def signature_context
  453. src = source
  454. # Use direct employee reference if available, otherwise try to get from source
  455. emp = employee || (src.respond_to?(:employee) ? src.employee : nil)
  456. {
  457. employee: emp,
  458. organization: organization,
  459. request: src
  460. }
  461. end
  462. def find_pending_signature_for(user)
  463. signatures.find do |s|
  464. s["user_id"] == user.id.to_s && s["status"] == "pending"
  465. end
  466. end
  467. def check_completion!
  468. return unless all_required_signed?
  469. # Generate final PDF with all signatures (non-blocking on error)
  470. begin
  471. Templates::PdfSignatureService.new(self).apply_all_signatures!
  472. rescue StandardError => e
  473. Rails.logger.error("Error generating final PDF: #{e.message}")
  474. Rails.logger.error(e.backtrace.first(5).join("\n"))
  475. # Continue to mark as completed even if PDF generation fails
  476. end
  477. update!(status: COMPLETED, completed_at: Time.current)
  478. end
  479. class SignatureError < StandardError; end
  480. end
  481. end

app/models/templates/signatory_type.rb

0.0% lines covered

90 relevant lines. 0 lines covered and 90 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class SignatoryType
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. store_in collection: "signatory_types"
  8. # Fields
  9. field :name, type: String # Display name, e.g., "Gerente de RR.HH."
  10. field :code, type: String # Unique code, e.g., "hr_manager"
  11. field :description, type: String # Description of this signatory type
  12. field :is_system, type: Boolean, default: false # System types can't be deleted
  13. field :active, type: Boolean, default: true
  14. field :position, type: Integer, default: 0
  15. # Associations
  16. belongs_to :organization, class_name: "Identity::Organization", optional: true
  17. belongs_to :created_by, class_name: "Identity::User", optional: true
  18. has_many :template_signatories, class_name: "Templates::TemplateSignatory",
  19. foreign_key: :signatory_type_id, dependent: :restrict_with_error
  20. # Indexes
  21. index({ organization_id: 1, active: 1 })
  22. index({ code: 1 }, { unique: true })
  23. index({ is_system: 1 })
  24. index({ position: 1 })
  25. # Validations
  26. validates :name, presence: true, length: { maximum: 100 }
  27. validates :code, presence: true, uniqueness: true, length: { maximum: 50 }
  28. validate :code_format_valid
  29. # Scopes
  30. scope :active, -> { where(active: true) }
  31. scope :inactive, -> { where(active: false) }
  32. scope :system_types, -> { where(is_system: true) }
  33. scope :custom_types, -> { where(is_system: false) }
  34. scope :for_organization, ->(org) { where(:organization_id.in => [nil, org&.id]) }
  35. scope :ordered, -> { order(position: :asc, name: :asc) }
  36. # Callbacks
  37. before_validation :generate_code, on: :create, if: -> { code.blank? }
  38. # Instance methods
  39. def system?
  40. is_system
  41. end
  42. def custom?
  43. !is_system
  44. end
  45. def activate!
  46. update!(active: true)
  47. end
  48. def deactivate!
  49. update!(active: false)
  50. end
  51. def toggle_active!
  52. update!(active: !active)
  53. end
  54. def in_use?
  55. template_signatories.exists?
  56. end
  57. def usage_count
  58. template_signatories.count
  59. end
  60. # Class methods
  61. class << self
  62. def available_for(organization)
  63. active.for_organization(organization).ordered
  64. end
  65. def seed_system_types!
  66. system_types_data.each do |data|
  67. find_or_create_by!(code: data[:code]) do |type|
  68. type.assign_attributes(data.merge(is_system: true))
  69. end
  70. end
  71. end
  72. private
  73. def system_types_data
  74. [
  75. { name: "Empleado Solicitante", code: "employee", description: "El empleado que realiza la solicitud", position: 1 },
  76. { name: "Supervisor Directo", code: "supervisor", description: "Supervisor inmediato del empleado", position: 2 },
  77. { name: "Recursos Humanos", code: "hr", description: "Personal de Recursos Humanos", position: 3 },
  78. { name: "Gerente de RR.HH.", code: "hr_manager", description: "Gerente del departamento de Recursos Humanos", position: 4 },
  79. { name: "Departamento Legal", code: "legal", description: "Personal del departamento legal", position: 5 },
  80. { name: "Gerente General", code: "general_manager", description: "Gerente general de la empresa", position: 6 },
  81. { name: "Representante Legal", code: "legal_representative", description: "Representante legal de la empresa", position: 7 },
  82. { name: "Contador", code: "accountant", description: "Contador o área contable", position: 8 },
  83. { name: "Administrador", code: "admin", description: "Administrador del sistema", position: 9 }
  84. ]
  85. end
  86. end
  87. private
  88. def generate_code
  89. base = name.to_s.parameterize(separator: "_")
  90. self.code = base
  91. end
  92. def code_format_valid
  93. return if code.blank?
  94. unless code.match?(/\A[a-z][a-z0-9_]*\z/)
  95. errors.add(:code, "debe comenzar con letra y contener solo letras minúsculas, números y guiones bajos")
  96. end
  97. end
  98. end
  99. end

app/models/templates/template.rb

0.0% lines covered

370 relevant lines. 0 lines covered and 370 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class Template
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. store_in collection: "templates"
  8. # Modules (which system module this template belongs to)
  9. MODULES = {
  10. "hr" => { label: "Recursos Humanos", icon: "users" },
  11. "legal" => { label: "Gestión Legal", icon: "scale" },
  12. "admin" => { label: "Administración", icon: "settings" }
  13. }.freeze
  14. # Mapping from main_category to default module
  15. CATEGORY_TO_MODULE = {
  16. "laboral" => "hr",
  17. "comercial" => "legal",
  18. "administrativo" => "admin"
  19. }.freeze
  20. # Main categories (top level)
  21. MAIN_CATEGORIES = {
  22. "laboral" => "Laboral",
  23. "comercial" => "Comercial",
  24. "administrativo" => "Administrativo"
  25. }.freeze
  26. # Subcategories for templates (grouped by main category)
  27. SUBCATEGORIES = {
  28. "certification" => { label: "Certificaciones", main: "laboral" },
  29. "vacation" => { label: "Vacaciones", main: "laboral" },
  30. "contract" => { label: "Contratos", main: "laboral" },
  31. "termination" => { label: "Terminación", main: "laboral" },
  32. "memo" => { label: "Memorandos", main: "administrativo" },
  33. "letter" => { label: "Cartas", main: "administrativo" },
  34. "policy" => { label: "Políticas", main: "administrativo" },
  35. "commercial_contract" => { label: "Contratos Comerciales", main: "comercial" },
  36. "proposal" => { label: "Propuestas", main: "comercial" },
  37. "agreement" => { label: "Acuerdos", main: "comercial" },
  38. "nda" => { label: "NDA/Confidencialidad", main: "comercial" },
  39. "other" => { label: "Otros", main: "administrativo" }
  40. }.freeze
  41. # Legacy alias for backward compatibility
  42. CATEGORIES = SUBCATEGORIES.transform_values { |v| v[:label] }.freeze
  43. # Status values
  44. DRAFT = "draft"
  45. ACTIVE = "active"
  46. ARCHIVED = "archived"
  47. STATUSES = [DRAFT, ACTIVE, ARCHIVED].freeze
  48. # Fields
  49. field :name, type: String
  50. field :description, type: String
  51. field :module_type, type: String, default: "hr" # hr, legal, admin
  52. field :main_category, type: String, default: "laboral"
  53. field :category, type: String, default: "other" # This is now the subcategory
  54. field :status, type: String, default: DRAFT
  55. field :version, type: Integer, default: 1
  56. # File storage (GridFS file ID)
  57. field :file_id, type: BSON::ObjectId
  58. field :file_name, type: String
  59. field :file_content_type, type: String
  60. field :file_size, type: Integer
  61. # PDF preview file (generated from Word for preview on servers without LibreOffice)
  62. field :preview_file_id, type: BSON::ObjectId
  63. # Extracted variables from template
  64. field :variables, type: Array, default: []
  65. # Variable mappings: { "Nombre Empleado" => "employee.full_name", ... }
  66. field :variable_mappings, type: Hash, default: {}
  67. # Default third party type for this template (provider, client, contractor, partner, other)
  68. field :default_third_party_type, type: String
  69. # For certification templates: which certification type this template is for
  70. # Maps to Hr::EmploymentCertificationRequest::CERTIFICATION_TYPES
  71. # (employment, salary, position, full, custom)
  72. field :certification_type, type: String
  73. # Preview settings for signature positioning
  74. field :preview_scale, type: Float, default: 0.7
  75. field :preview_page_height, type: Integer, default: 792 # Letter size height
  76. # Actual PDF dimensions (extracted from uploaded file)
  77. field :pdf_width, type: Float
  78. field :pdf_height, type: Float
  79. field :pdf_page_count, type: Integer, default: 1
  80. # Signature workflow options
  81. # When true, signatories must sign in order (by position)
  82. # Each signatory can only sign after all previous signatories have signed
  83. field :sequential_signing, type: Boolean, default: true
  84. # Associations
  85. belongs_to :organization, class_name: "Identity::Organization"
  86. belongs_to :created_by, class_name: "Identity::User", optional: true
  87. has_many :signatories, class_name: "Templates::TemplateSignatory", dependent: :destroy
  88. has_many :generated_documents, class_name: "Templates::GeneratedDocument", dependent: :nullify
  89. # Indexes
  90. index({ organization_id: 1 })
  91. index({ module_type: 1 })
  92. index({ main_category: 1 })
  93. index({ category: 1 })
  94. index({ status: 1 })
  95. index({ name: 1 })
  96. index({ organization_id: 1, module_type: 1, main_category: 1, category: 1, status: 1 })
  97. index({ organization_id: 1, category: 1, certification_type: 1, status: 1 })
  98. # Validations
  99. validates :name, presence: true, length: { maximum: 200 }
  100. validates :module_type, presence: true, inclusion: { in: MODULES.keys }
  101. validates :main_category, presence: true, inclusion: { in: MAIN_CATEGORIES.keys }
  102. validates :category, presence: true, inclusion: { in: SUBCATEGORIES.keys }
  103. validates :status, presence: true, inclusion: { in: STATUSES }
  104. validates :file_id, presence: true, if: -> { active? }
  105. # Callbacks
  106. before_validation :infer_module_from_category, if: -> { main_category_changed? && module_type.blank? }
  107. # Scopes
  108. scope :draft, -> { where(status: DRAFT) }
  109. scope :active, -> { where(status: ACTIVE) }
  110. scope :archived, -> { where(status: ARCHIVED) }
  111. scope :by_module, ->(mod) { where(module_type: mod) }
  112. scope :for_hr, -> { where(module_type: "hr") }
  113. scope :for_legal, -> { where(module_type: "legal") }
  114. scope :for_admin, -> { where(module_type: "admin") }
  115. scope :by_main_category, ->(main_cat) { where(main_category: main_cat) }
  116. scope :by_category, ->(category) { where(category: category) }
  117. scope :by_subcategory, ->(subcategory) { where(category: subcategory) }
  118. scope :for_organization, ->(org) { where(organization_id: org.id) }
  119. scope :for_certification_type, ->(cert_type) { where(certification_type: cert_type) }
  120. # Instance methods
  121. def draft?
  122. status == DRAFT
  123. end
  124. def active?
  125. status == ACTIVE
  126. end
  127. def archived?
  128. status == ARCHIVED
  129. end
  130. def activate!
  131. raise InvalidStateError, "Template debe tener archivo adjunto para activar" unless file_id
  132. update!(status: ACTIVE)
  133. end
  134. def archive!
  135. update!(status: ARCHIVED)
  136. end
  137. def reactivate!
  138. update!(status: ACTIVE)
  139. end
  140. def duplicate!
  141. dup.tap do |new_template|
  142. new_template.name = "#{name} (copia)"
  143. new_template.status = DRAFT
  144. new_template.version = 1
  145. new_template.uuid = nil
  146. new_template.save!
  147. # Duplicate signatories
  148. signatories.each do |sig|
  149. new_sig = sig.dup
  150. new_sig.template = new_template
  151. new_sig.uuid = nil
  152. new_sig.save!
  153. end
  154. end
  155. end
  156. def module_type_label
  157. MODULES.dig(module_type, :label) || module_type
  158. end
  159. def module_type_icon
  160. MODULES.dig(module_type, :icon) || "file"
  161. end
  162. def main_category_label
  163. MAIN_CATEGORIES[main_category] || main_category
  164. end
  165. def category_label
  166. SUBCATEGORIES.dig(category, :label) || category
  167. end
  168. # Alias for clarity
  169. def subcategory_label
  170. category_label
  171. end
  172. # Infer module_type from main_category
  173. def infer_module_from_category
  174. self.module_type = CATEGORY_TO_MODULE[main_category] || "admin"
  175. end
  176. # Infer main_category from subcategory if not set
  177. def infer_main_category!
  178. return if main_category.present?
  179. self.main_category = SUBCATEGORIES.dig(category, :main) || "administrativo"
  180. end
  181. def required_signatories
  182. signatories.required
  183. end
  184. def optional_signatories
  185. signatories.optional
  186. end
  187. # File handling with GridFS
  188. def attach_file(io, filename:, content_type:)
  189. # Ensure we read the IO content
  190. io.rewind if io.respond_to?(:rewind)
  191. content = io.read
  192. io.rewind if io.respond_to?(:rewind)
  193. # Store in GridFS
  194. file = Mongoid::GridFs.put(
  195. StringIO.new(content),
  196. filename: filename,
  197. content_type: content_type
  198. )
  199. self.file_id = file.id
  200. self.file_name = filename
  201. self.file_content_type = content_type
  202. self.file_size = content.bytesize
  203. # Extract variables from the uploaded document
  204. extract_variables! if file_name&.end_with?(".docx")
  205. # Extract PDF dimensions after saving (need to convert docx to PDF first if needed)
  206. extract_pdf_dimensions!
  207. save!
  208. end
  209. def file_content
  210. return nil unless file_id
  211. file = Mongoid::GridFs.get(file_id)
  212. file.data
  213. rescue StandardError => e
  214. Rails.logger.error "Error reading file from GridFS: #{e.message}"
  215. nil
  216. end
  217. def extract_variables!
  218. return unless file_id
  219. content = file_content
  220. return unless content
  221. # Use TemplateParserService to extract variables
  222. self.variables = TemplateParserService.new(content).extract_variables
  223. # Auto-assign mappings from system variables
  224. auto_assign_mappings!
  225. end
  226. def extract_pdf_dimensions!
  227. return unless file_id
  228. begin
  229. content = file_content
  230. return unless content
  231. # Get PDF content - either directly or by converting docx
  232. pdf_content = if file_name&.end_with?(".pdf")
  233. content
  234. elsif file_name&.end_with?(".docx")
  235. convert_docx_to_pdf_for_dimensions(content)
  236. end
  237. return unless pdf_content
  238. # Store the PDF preview in GridFS for servers without LibreOffice
  239. if file_name&.end_with?(".docx")
  240. store_pdf_preview!(pdf_content)
  241. end
  242. require "combine_pdf"
  243. pdf = CombinePDF.parse(pdf_content)
  244. return if pdf.pages.empty?
  245. first_page = pdf.pages.first
  246. mediabox = first_page.mediabox
  247. self.pdf_width = mediabox[2].to_f
  248. self.pdf_height = mediabox[3].to_f
  249. self.pdf_page_count = pdf.pages.count
  250. # Also update preview_page_height to match actual PDF
  251. self.preview_page_height = pdf_height.to_i if pdf_height.present?
  252. Rails.logger.info "Extracted PDF dimensions: #{pdf_width}x#{pdf_height}, #{pdf_page_count} pages"
  253. rescue StandardError => e
  254. Rails.logger.warn "Could not extract PDF dimensions: #{e.message}"
  255. Rails.logger.warn e.backtrace.first(3).join("\n")
  256. # Set default Letter size if extraction fails
  257. self.pdf_width ||= 612.0
  258. self.pdf_height ||= 792.0
  259. self.pdf_page_count ||= 1
  260. end
  261. end
  262. def store_pdf_preview!(pdf_content)
  263. return unless pdf_content
  264. # Delete old preview if exists
  265. if preview_file_id
  266. begin
  267. Mongoid::GridFs.delete(preview_file_id)
  268. rescue StandardError
  269. nil
  270. end
  271. end
  272. # Store new PDF preview
  273. preview_filename = file_name&.sub(/\.docx$/i, ".pdf") || "preview.pdf"
  274. file = Mongoid::GridFs.put(
  275. StringIO.new(pdf_content),
  276. filename: preview_filename,
  277. content_type: "application/pdf"
  278. )
  279. self.preview_file_id = file.id
  280. Rails.logger.info "Stored PDF preview: #{preview_filename} (#{pdf_content.bytesize} bytes)"
  281. end
  282. def preview_content
  283. return nil unless preview_file_id
  284. file = Mongoid::GridFs.get(preview_file_id)
  285. file.data
  286. rescue Mongoid::Errors::DocumentNotFound
  287. nil
  288. end
  289. def convert_docx_to_pdf_for_dimensions(docx_content)
  290. require "tempfile"
  291. require "fileutils"
  292. # Write DOCX to temp file
  293. docx_temp = Tempfile.new(["template", ".docx"])
  294. docx_temp.binmode
  295. docx_temp.write(docx_content)
  296. docx_temp.close
  297. temp_dir = Dir.mktmpdir
  298. begin
  299. # Find LibreOffice
  300. soffice_path = `which soffice`.strip
  301. soffice_path = "/opt/homebrew/bin/soffice" if soffice_path.empty? && File.exist?("/opt/homebrew/bin/soffice")
  302. soffice_path = "/usr/bin/soffice" if soffice_path.empty? && File.exist?("/usr/bin/soffice")
  303. unless File.exist?(soffice_path.to_s)
  304. Rails.logger.warn "LibreOffice not found for PDF conversion"
  305. return nil
  306. end
  307. # Convert to PDF
  308. system(soffice_path, "--headless", "--convert-to", "pdf", "--outdir", temp_dir, docx_temp.path)
  309. pdf_path = File.join(temp_dir, File.basename(docx_temp.path).sub(".docx", ".pdf"))
  310. return nil unless File.exist?(pdf_path)
  311. File.binread(pdf_path)
  312. ensure
  313. docx_temp.unlink
  314. FileUtils.rm_rf(temp_dir)
  315. end
  316. end
  317. # Auto-assign template variables to system mappings based on name equivalence
  318. def auto_assign_mappings!
  319. return if variables.blank?
  320. # Get all available mappings for this organization
  321. available_mappings = VariableMapping.for_organization(organization).active.to_a
  322. variables.each do |variable|
  323. # Skip if already mapped
  324. next if variable_mappings[variable].present?
  325. # Find matching system mapping using VariableNormalizer.equivalent?
  326. matching_mapping = available_mappings.find do |vm|
  327. VariableNormalizer.equivalent?(variable, vm.name)
  328. end
  329. if matching_mapping
  330. variable_mappings[variable] = matching_mapping.key
  331. end
  332. end
  333. save if changed?
  334. end
  335. # Re-assign all mappings (even existing ones) from system variables
  336. def reassign_all_mappings!
  337. return if variables.blank?
  338. available_mappings = VariableMapping.for_organization(organization).active.to_a
  339. new_mappings = {}
  340. variables.each do |variable|
  341. matching_mapping = available_mappings.find do |vm|
  342. VariableNormalizer.equivalent?(variable, vm.name)
  343. end
  344. if matching_mapping
  345. new_mappings[variable] = matching_mapping.key
  346. elsif variable_mappings[variable].present?
  347. # Keep existing custom mapping
  348. new_mappings[variable] = variable_mappings[variable]
  349. end
  350. end
  351. update!(variable_mappings: new_mappings)
  352. end
  353. # Get available variable mappings from database
  354. def self.available_variable_mappings(organization = nil)
  355. VariableMapping.to_mapping_hash(organization)
  356. end
  357. # Get grouped mappings for UI
  358. def self.grouped_variable_mappings(organization = nil)
  359. VariableMapping.grouped_for(organization)
  360. end
  361. # Get required third party fields based on template variables
  362. # Returns array of field info: [{ key: "business_name", label: "Razón Social", required: true }, ...]
  363. def required_third_party_fields
  364. return [] if variables.blank?
  365. # Map of variable keys to third party fields
  366. variable_to_field_map = {
  367. "third_party.display_name" => { field: "business_name", label: "Razón Social/Nombre", person_type: nil },
  368. "third_party.business_name" => { field: "business_name", label: "Razón Social", person_type: "juridical" },
  369. "third_party.trade_name" => { field: "trade_name", label: "Nombre Comercial", person_type: nil },
  370. "third_party.first_name" => { field: "first_name", label: "Nombre", person_type: "natural" },
  371. "third_party.last_name" => { field: "last_name", label: "Apellido", person_type: "natural" },
  372. "third_party.identification_number" => { field: "identification_number", label: "Número de Identificación", person_type: nil },
  373. "third_party.identification_type" => { field: "identification_type", label: "Tipo de Identificación", person_type: nil },
  374. "third_party.full_identification" => { field: "identification_number", label: "Identificación Completa", person_type: nil },
  375. "third_party.verification_digit" => { field: "verification_digit", label: "Dígito de Verificación", person_type: "juridical" },
  376. "third_party.email" => { field: "email", label: "Correo Electrónico", person_type: nil },
  377. "third_party.phone" => { field: "phone", label: "Teléfono", person_type: nil },
  378. "third_party.mobile" => { field: "mobile", label: "Celular", person_type: nil },
  379. "third_party.address" => { field: "address", label: "Dirección", person_type: nil },
  380. "third_party.city" => { field: "city", label: "Ciudad", person_type: nil },
  381. "third_party.state" => { field: "state", label: "Departamento/Estado", person_type: nil },
  382. "third_party.country" => { field: "country", label: "País", person_type: nil },
  383. "third_party.legal_rep_name" => { field: "legal_rep_name", label: "Nombre Representante Legal", person_type: "juridical" },
  384. "third_party.legal_rep_id" => { field: "legal_rep_id_number", label: "Cédula Representante Legal", person_type: "juridical" },
  385. "third_party.legal_rep_id_number" => { field: "legal_rep_id_number", label: "Cédula Representante Legal", person_type: "juridical" },
  386. "third_party.legal_rep_id_type" => { field: "legal_rep_id_type", label: "Tipo ID Representante Legal", person_type: "juridical" },
  387. "third_party.legal_rep_id_city" => { field: "legal_rep_id_city", label: "Ciudad Expedición Cédula Rep. Legal", person_type: "juridical" },
  388. "third_party.legal_rep_email" => { field: "legal_rep_email", label: "Email Representante Legal", person_type: "juridical" },
  389. "third_party.legal_rep_phone" => { field: "legal_rep_phone", label: "Teléfono Representante Legal", person_type: "juridical" },
  390. "third_party.bank_name" => { field: "bank_name", label: "Banco", person_type: nil },
  391. "third_party.bank_account_type" => { field: "bank_account_type", label: "Tipo de Cuenta", person_type: nil },
  392. "third_party.bank_account_number" => { field: "bank_account_number", label: "Número de Cuenta", person_type: nil },
  393. "third_party.tax_regime" => { field: "tax_regime", label: "Régimen Tributario", person_type: nil },
  394. "third_party.industry" => { field: "industry", label: "Industria/Sector", person_type: nil },
  395. "third_party.website" => { field: "website", label: "Sitio Web", person_type: nil }
  396. }
  397. required_fields = []
  398. variables.each do |variable|
  399. mapping_key = variable_mappings[variable]
  400. next unless mapping_key&.start_with?("third_party.")
  401. field_info = variable_to_field_map[mapping_key]
  402. next unless field_info
  403. # Avoid duplicates
  404. next if required_fields.any? { |f| f[:field] == field_info[:field] }
  405. required_fields << {
  406. field: field_info[:field],
  407. label: field_info[:label],
  408. variable: variable,
  409. person_type: field_info[:person_type],
  410. required: true
  411. }
  412. end
  413. required_fields
  414. end
  415. # Check if template uses third party variables
  416. def uses_third_party_variables?
  417. return false if variable_mappings.blank?
  418. variable_mappings.values.any? { |v| v&.start_with?("third_party.") }
  419. end
  420. # Get suggested person_type based on required fields
  421. def suggested_person_type
  422. fields = required_third_party_fields
  423. has_juridical = fields.any? { |f| f[:person_type] == "juridical" }
  424. has_natural = fields.any? { |f| f[:person_type] == "natural" }
  425. return "juridical" if has_juridical && !has_natural
  426. return "natural" if has_natural && !has_juridical
  427. nil # Both or neither - let user choose
  428. end
  429. class InvalidStateError < StandardError; end
  430. end
  431. end

app/models/templates/template_signatory.rb

0.0% lines covered

161 relevant lines. 0 lines covered and 161 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class TemplateSignatory
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. store_in collection: "template_signatories"
  8. # Legacy signatory roles (kept for backward compatibility)
  9. EMPLOYEE = "employee"
  10. SUPERVISOR = "supervisor"
  11. HR = "hr"
  12. HR_MANAGER = "hr_manager"
  13. LEGAL = "legal"
  14. ADMIN = "admin"
  15. CUSTOM = "custom"
  16. ROLES = [EMPLOYEE, SUPERVISOR, HR, HR_MANAGER, LEGAL, ADMIN, CUSTOM].freeze
  17. ROLE_LABELS = {
  18. EMPLOYEE => "Empleado Solicitante",
  19. SUPERVISOR => "Supervisor Directo",
  20. HR => "Recursos Humanos",
  21. HR_MANAGER => "Gerente de RR.HH.",
  22. LEGAL => "Departamento Legal",
  23. ADMIN => "Administrador",
  24. CUSTOM => "Personalizado"
  25. }.freeze
  26. # Fields
  27. field :role, type: String # Legacy field, use signatory_type_code for new entries
  28. field :signatory_type_code, type: String # Reference to SignatoryType by code
  29. field :label, type: String # Display label, e.g., "Firma del Empleado"
  30. field :position, type: Integer, default: 0 # Order of signature
  31. field :required, type: Boolean, default: true
  32. field :placeholder_text, type: String, default: "Firma"
  33. # Signature placement on PDF (coordinates relative to page)
  34. field :page_number, type: Integer, default: 1 # 0 = last page
  35. field :x_position, type: Float, default: 100.0
  36. field :y_position, type: Float, default: 100.0
  37. field :width, type: Float, default: 200.0
  38. field :height, type: Float, default: 60.0
  39. # Date position relative to signature
  40. # right: fecha a la derecha (default, firma usa 75% ancho)
  41. # below: fecha debajo (firma usa 100% ancho, 80% alto)
  42. # above: fecha arriba (firma usa 100% ancho, 80% alto)
  43. # none: sin fecha (firma usa 100% del espacio)
  44. DATE_POSITIONS = %w[right below above none].freeze
  45. field :date_position, type: String, default: "right"
  46. # Display options for signature rendering
  47. field :show_label, type: Boolean, default: true # Show label (e.g., "Representante Legal")
  48. field :show_signer_name, type: Boolean, default: false # Show "Firmado por: [nombre]"
  49. # For custom role - specific user or email
  50. field :custom_user_id, type: BSON::ObjectId
  51. field :custom_email, type: String
  52. # Associations
  53. belongs_to :template, class_name: "Templates::Template", inverse_of: :signatories
  54. # Indexes
  55. index({ template_id: 1, position: 1 })
  56. index({ role: 1 })
  57. # Validations
  58. validates :label, presence: true, length: { maximum: 100 }
  59. validate :valid_signatory_type
  60. validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
  61. validates :x_position, :y_position, :width, :height,
  62. numericality: { greater_than_or_equal_to: 0 }
  63. validates :custom_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
  64. validates :date_position, inclusion: { in: DATE_POSITIONS }, allow_blank: true
  65. # Scopes
  66. scope :required, -> { where(required: true) }
  67. scope :optional, -> { where(required: false) }
  68. scope :by_position, -> { order(position: :asc) }
  69. # Callbacks
  70. before_validation :set_default_label, on: :create
  71. # Instance methods
  72. def role_label
  73. # Try to get label from SignatoryType first
  74. if signatory_type_code.present?
  75. signatory_type&.name || signatory_type_code
  76. else
  77. ROLE_LABELS[role] || role
  78. end
  79. end
  80. def effective_code
  81. signatory_type_code.presence || role
  82. end
  83. def signatory_type
  84. return nil if signatory_type_code.blank?
  85. @signatory_type ||= SignatoryType.find_by(code: signatory_type_code)
  86. end
  87. def custom?
  88. effective_code == CUSTOM
  89. end
  90. def employee_signatory?
  91. role == EMPLOYEE
  92. end
  93. # Find the appropriate user to sign based on role and context
  94. def find_signatory_for(context)
  95. # Use signatory_type_code if role is blank
  96. effective_role = role.presence || signatory_type_code
  97. case effective_role
  98. when EMPLOYEE, "employee"
  99. context[:employee]&.user
  100. when SUPERVISOR, "supervisor"
  101. context[:employee]&.supervisor&.user
  102. when HR, "hr"
  103. find_user_with_role("hr", context[:organization])
  104. when HR_MANAGER, "hr_manager"
  105. find_user_with_role("hr_manager", context[:organization]) || find_user_with_role("hr", context[:organization])
  106. when LEGAL, "legal"
  107. find_user_with_role("legal", context[:organization])
  108. when "legal_representative"
  109. find_user_with_role("legal_representative", context[:organization])
  110. when "general_manager"
  111. find_user_with_role("general_manager", context[:organization])
  112. when "ceo"
  113. find_user_with_role("ceo", context[:organization])
  114. when "accountant"
  115. find_user_with_role("accountant", context[:organization])
  116. when "manager", "area_manager"
  117. find_user_with_role("manager", context[:organization])
  118. when ADMIN, "admin"
  119. find_user_with_role("admin", context[:organization])
  120. when CUSTOM, "custom"
  121. find_custom_signatory
  122. else
  123. # Try to find by role name directly
  124. find_user_with_role(effective_role, context[:organization])
  125. end
  126. end
  127. # Signature box coordinates for PDF rendering
  128. def signature_box
  129. {
  130. x: x_position,
  131. y: y_position,
  132. width: width,
  133. height: height,
  134. page: page_number,
  135. date_position: date_position || "right",
  136. show_label: show_label.nil? ? true : show_label,
  137. show_signer_name: show_signer_name || false
  138. }
  139. end
  140. private
  141. def valid_signatory_type
  142. # Must have either role or signatory_type_code
  143. if role.blank? && signatory_type_code.blank?
  144. errors.add(:base, "Debe especificar un tipo de firmante")
  145. return
  146. end
  147. # If using legacy role, validate it
  148. if role.present? && signatory_type_code.blank?
  149. unless ROLES.include?(role)
  150. errors.add(:role, "no es un rol válido")
  151. end
  152. end
  153. # If using signatory_type_code, validate it exists
  154. if signatory_type_code.present?
  155. unless SignatoryType.exists?(code: signatory_type_code)
  156. errors.add(:signatory_type_code, "no es un tipo de firmante válido")
  157. end
  158. end
  159. end
  160. def set_default_label
  161. return if label.present?
  162. if signatory_type_code.present?
  163. self.label = signatory_type&.name || "Firma"
  164. else
  165. self.label = ROLE_LABELS[role] || "Firma"
  166. end
  167. end
  168. # Generic method to find a user with a specific role in an organization
  169. def find_user_with_role(role_name, organization)
  170. return nil unless organization
  171. role = Identity::Role.where(name: role_name).first
  172. return nil unless role
  173. Identity::User.where(
  174. organization_id: organization.id,
  175. :role_ids.in => [role.id],
  176. active: true
  177. ).first
  178. end
  179. def find_custom_signatory
  180. if custom_user_id.present?
  181. Identity::User.where(id: custom_user_id, active: true).first
  182. elsif custom_email.present?
  183. Identity::User.where(email: custom_email, active: true).first
  184. end
  185. end
  186. end
  187. end

app/models/templates/variable_mapping.rb

0.0% lines covered

262 relevant lines. 0 lines covered and 262 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class VariableMapping
  4. include Mongoid::Document
  5. include Mongoid::Timestamps
  6. include UuidIdentifiable
  7. store_in collection: "variable_mappings"
  8. # Categories for organizing mappings
  9. CATEGORIES = {
  10. "employee" => "Empleado",
  11. "organization" => "Organización",
  12. "request" => "Solicitud",
  13. "third_party" => "Tercero",
  14. "contract" => "Contrato",
  15. "system" => "Sistema",
  16. "custom" => "Personalizado"
  17. }.freeze
  18. # Data types for value resolution
  19. DATA_TYPES = %w[string date number boolean email].freeze
  20. # Fields
  21. field :name, type: String # Display name, e.g., "Salario Mensual"
  22. field :key, type: String # Unique key, e.g., "employee.monthly_salary"
  23. field :category, type: String # Category for grouping
  24. field :description, type: String # Help text
  25. field :data_type, type: String, default: "string"
  26. field :format_pattern, type: String # Optional format, e.g., "$%{value}" for currency
  27. field :is_system, type: Boolean, default: false # System mappings can't be deleted
  28. field :active, type: Boolean, default: true
  29. field :position, type: Integer, default: 0
  30. field :aliases, type: Array, default: [] # Alternative names that map to the same key
  31. # For custom mappings that pull from specific model fields
  32. field :source_model, type: String # e.g., "Hr::Employee"
  33. field :source_field, type: String # e.g., "monthly_salary"
  34. # Associations
  35. belongs_to :organization, class_name: "Identity::Organization", optional: true
  36. belongs_to :created_by, class_name: "Identity::User", optional: true
  37. # Indexes
  38. index({ organization_id: 1, active: 1 })
  39. index({ key: 1 })
  40. index({ name: 1, is_system: 1 }, { unique: true })
  41. index({ category: 1 })
  42. index({ is_system: 1 })
  43. index({ position: 1 })
  44. # Validations
  45. validates :name, presence: true, length: { maximum: 100 }, uniqueness: { scope: :is_system }
  46. validates :key, presence: true, length: { maximum: 100 }
  47. validates :category, presence: true, inclusion: { in: CATEGORIES.keys }
  48. validates :data_type, inclusion: { in: DATA_TYPES }
  49. validate :key_format_valid
  50. # Scopes
  51. scope :active, -> { where(active: true) }
  52. scope :inactive, -> { where(active: false) }
  53. scope :system_mappings, -> { where(is_system: true) }
  54. scope :custom_mappings, -> { where(is_system: false) }
  55. scope :by_category, ->(cat) { where(category: cat) }
  56. scope :for_organization, ->(org) { where(:organization_id.in => [nil, org.id]) }
  57. scope :ordered, -> { order(category: :asc, position: :asc, name: :asc) }
  58. # Callbacks
  59. before_validation :generate_key, on: :create, if: -> { key.blank? }
  60. before_validation :normalize_name
  61. # Instance methods
  62. def system?
  63. is_system
  64. end
  65. def custom?
  66. !is_system
  67. end
  68. def category_label
  69. CATEGORIES[category] || category
  70. end
  71. def activate!
  72. update!(active: true)
  73. end
  74. def deactivate!
  75. update!(active: false)
  76. end
  77. def toggle_active!
  78. update!(active: !active)
  79. end
  80. # Add an alias to this mapping
  81. def add_alias(alias_name)
  82. normalized = VariableNormalizer.normalize(alias_name)
  83. return false if normalized == name || aliases.include?(normalized)
  84. self.aliases = (aliases + [normalized]).uniq
  85. save!
  86. end
  87. # Remove an alias
  88. def remove_alias(alias_name)
  89. normalized = VariableNormalizer.normalize(alias_name)
  90. return false unless aliases.include?(normalized)
  91. self.aliases = aliases - [normalized]
  92. save!
  93. end
  94. # Check if a name matches this mapping (name or any alias)
  95. def matches_name?(search_name)
  96. normalized_search = VariableNormalizer.comparison_key(search_name)
  97. return true if VariableNormalizer.comparison_key(name) == normalized_search
  98. aliases.any? { |a| VariableNormalizer.comparison_key(a) == normalized_search }
  99. end
  100. # All names (primary + aliases)
  101. def all_names
  102. [name] + (aliases || [])
  103. end
  104. # Resolve the value for this mapping given a context
  105. def resolve_value(context)
  106. return nil unless active?
  107. if source_model.present? && source_field.present?
  108. resolve_from_source(context)
  109. else
  110. resolve_from_path(context)
  111. end
  112. end
  113. # Class methods
  114. class << self
  115. # Get all available mappings (system + org custom)
  116. def available_for(organization)
  117. active.for_organization(organization).ordered
  118. end
  119. # Convert to hash format for API
  120. def to_mapping_hash(organization = nil)
  121. mappings = organization ? available_for(organization) : active.ordered
  122. mappings.each_with_object({}) do |mapping, hash|
  123. hash[mapping.name] = mapping.key
  124. end
  125. end
  126. # Grouped by category
  127. def grouped_for(organization)
  128. available_for(organization).group_by(&:category)
  129. end
  130. # Find mapping by name or alias (case/accent insensitive)
  131. def find_by_name_or_alias(search_name, organization = nil)
  132. mappings = organization ? available_for(organization) : active.ordered
  133. normalized_search = VariableNormalizer.comparison_key(search_name)
  134. mappings.find do |m|
  135. m.matches_name?(search_name)
  136. end
  137. end
  138. # Seed system mappings
  139. def seed_system_mappings!
  140. system_mappings_data.each do |data|
  141. normalized_name = VariableNormalizer.normalize(data[:name])
  142. # Use name as unique identifier (allows multiple names for same key)
  143. mapping = where(name: normalized_name, is_system: true).first
  144. if mapping
  145. mapping.update!(data.merge(is_system: true, name: normalized_name))
  146. else
  147. create!(data.merge(is_system: true, name: normalized_name))
  148. end
  149. end
  150. end
  151. private
  152. def system_mappings_data
  153. [
  154. # Employee mappings - Personal info
  155. { name: "Nombre Completo", key: "employee.full_name", category: "employee", description: "Nombre y apellido del empleado" },
  156. { name: "Nombre del Trabajador", key: "employee.full_name", category: "employee", description: "Nombre completo del trabajador" },
  157. { name: "Primer Nombre", key: "employee.first_name", category: "employee", description: "Primer nombre del empleado" },
  158. { name: "Apellido", key: "employee.last_name", category: "employee", description: "Apellido del empleado" },
  159. { name: "Numero de Empleado", key: "employee.employee_number", category: "employee", description: "Código único del empleado" },
  160. { name: "Cargo", key: "employee.job_title", category: "employee", description: "Cargo o posición del empleado" },
  161. { name: "Nombre del Cargo", key: "employee.job_title", category: "employee", description: "Cargo o posición" },
  162. { name: "Departamento", key: "employee.department", category: "employee", description: "Departamento donde trabaja" },
  163. { name: "Numero de Identificacion", key: "employee.identification_number", category: "employee", description: "Número de cédula o documento" },
  164. { name: "Cedula", key: "employee.identification_number", category: "employee", description: "Número de cédula" },
  165. { name: "Cc del Trabajador", key: "employee.identification_number", category: "employee", description: "Cédula de ciudadanía del trabajador" },
  166. { name: "Tipo de Identificacion", key: "employee.identification_type", category: "employee", description: "Tipo de documento (CC, CE, etc.)" },
  167. { name: "Email del Empleado", key: "employee.email", category: "employee", description: "Correo electrónico" },
  168. { name: "Fecha de Nacimiento", key: "employee.date_of_birth", category: "employee", data_type: "date", description: "Fecha de nacimiento" },
  169. { name: "Lugar de Nacimiento", key: "employee.place_of_birth", category: "employee", description: "Lugar de nacimiento" },
  170. { name: "Nacionalidad", key: "employee.nationality", category: "employee", description: "Nacionalidad del empleado" },
  171. { name: "Direccion del Trabajador", key: "employee.address", category: "employee", description: "Dirección del trabajador" },
  172. { name: "Telefono del Trabajador", key: "employee.phone", category: "employee", description: "Teléfono del trabajador" },
  173. # Employee mappings - Contract & compensation
  174. { name: "Fecha de Ingreso", key: "employee.hire_date", category: "employee", data_type: "date", description: "Fecha de contratación" },
  175. { name: "Fecha de Contratacion", key: "employee.hire_date", category: "employee", data_type: "date", description: "Fecha de contratación (alias)" },
  176. { name: "Fecha de Inicio del Contrato", key: "employee.contract_start_date", category: "employee", data_type: "date", description: "Fecha de inicio del contrato (usa fecha de ingreso si no está definida)" },
  177. { name: "Fecha de Terminacion del Contrato", key: "employee.contract_end_date", category: "employee", data_type: "date", description: "Fecha de terminación del contrato" },
  178. { name: "Fecha de Terminacion", key: "employee.contract_end_date", category: "employee", data_type: "date", description: "Fecha de terminación (alias)" },
  179. { name: "Tipo de Contrato", key: "employee.contract_type", category: "employee", description: "Tipo de contrato laboral" },
  180. { name: "Termino de Duracion", key: "employee.contract_duration", category: "employee", description: "Término de duración del contrato" },
  181. { name: "Dias de Periodo de Prueba", key: "employee.trial_period_days", category: "employee", data_type: "number", description: "Días del periodo de prueba" },
  182. { name: "Anos de Servicio", key: "employee.years_of_service", category: "employee", data_type: "number", description: "Antigüedad en años" },
  183. { name: "Anos de Servicio Texto", key: "employee.years_of_service_text", category: "employee", description: "Antigüedad en texto" },
  184. { name: "Salario", key: "employee.salary", category: "employee", data_type: "number", description: "Salario mensual del empleado" },
  185. { name: "Salario en Letras", key: "employee.salary_text", category: "employee", description: "Salario en palabras" },
  186. { name: "Auxilio de Transporte", key: "employee.transport_allowance", category: "employee", data_type: "number", description: "Auxilio de transporte mensual" },
  187. { name: "Auxilio de Transporte en Letras", key: "employee.transport_allowance_text", category: "employee", description: "Auxilio de transporte en palabras" },
  188. { name: "Auxilio de Alimentacion", key: "employee.food_allowance", category: "employee", data_type: "number", description: "Auxilio de alimentación mensual" },
  189. { name: "Auxilio de Alimentacion en Letras", key: "employee.food_allowance_text", category: "employee", description: "Auxilio de alimentación en palabras" },
  190. { name: "Compensacion Total", key: "employee.total_compensation", category: "employee", data_type: "number", description: "Total salario + auxilios" },
  191. { name: "Compensacion Total en Letras", key: "employee.total_compensation_text", category: "employee", description: "Total salario + auxilios en palabras" },
  192. # Organization mappings
  193. { name: "Nombre de Empresa", key: "organization.name", category: "organization", description: "Razón social de la empresa" },
  194. { name: "Nit", key: "organization.tax_id", category: "organization", description: "Identificación tributaria" },
  195. { name: "Direccion de la Empresa", key: "organization.address", category: "organization", description: "Dirección de la empresa" },
  196. { name: "Ciudad", key: "organization.city", category: "organization", description: "Ciudad de la empresa" },
  197. { name: "Telefono de la Empresa", key: "organization.phone", category: "organization", description: "Teléfono de contacto" },
  198. # System mappings
  199. { name: "Fecha Actual", key: "system.current_date", category: "system", data_type: "date", description: "Fecha del día de generación" },
  200. { name: "Dia/Mes/Ano", key: "system.current_date", category: "system", data_type: "date", description: "Fecha actual en formato día/mes/año" },
  201. { name: "Fecha Actual Texto", key: "system.current_date_text", category: "system", description: "Fecha en formato largo" },
  202. { name: "Ano Actual", key: "system.current_year", category: "system", description: "Año de generación" },
  203. { name: "Mes Actual", key: "system.current_month", category: "system", description: "Mes de generación" },
  204. # Request mappings (for certifications/vacations)
  205. { name: "Numero de Solicitud", key: "request.request_number", category: "request", description: "Número único de la solicitud" },
  206. { name: "Tipo de Certificacion", key: "request.certification_type", category: "request", description: "Tipo de certificación solicitada" },
  207. { name: "Proposito", key: "request.purpose", category: "request", description: "Propósito de la solicitud" },
  208. { name: "Fecha de Inicio de Vacaciones", key: "request.start_date", category: "request", data_type: "date", description: "Fecha de inicio de vacaciones" },
  209. { name: "Fecha de Fin de Vacaciones", key: "request.end_date", category: "request", data_type: "date", description: "Fecha de fin de vacaciones" },
  210. { name: "Dias Solicitados", key: "request.days_requested", category: "request", data_type: "number", description: "Cantidad de días solicitados" },
  211. # Custom mappings - Text conversions (valores tomados del empleado y convertidos a texto)
  212. { name: "Salario Letras y Pesos", key: "custom.salario_letras_y_pesos", category: "custom", description: "Salario en palabras (toma de employee.salary)" },
  213. { name: "Auxilio Alimentacion en Letras y Pesos", key: "custom.auxilio_alimentacion_en_letras_y_pesos", category: "custom", description: "Auxilio alimentación en palabras (toma de employee.food_allowance)" },
  214. { name: "Auxilio Transporte en Letras y Pesos", key: "custom.auxilio_transporte_en_letras_y_pesos", category: "custom", description: "Auxilio transporte en palabras (toma de employee.transport_allowance)" },
  215. { name: "Compensacion Total en Letras", key: "custom.compensacion_total_en_letras", category: "custom", description: "Compensación total en palabras (salario + auxilios)" },
  216. # Third Party mappings (Terceros - Módulo Legal)
  217. { name: "Nombre del Tercero", key: "third_party.display_name", category: "third_party", description: "Nombre o razón social del tercero" },
  218. { name: "Razon Social", key: "third_party.business_name", category: "third_party", description: "Razón social de persona jurídica" },
  219. { name: "Nombre Comercial", key: "third_party.trade_name", category: "third_party", description: "Nombre comercial del tercero" },
  220. { name: "Codigo del Tercero", key: "third_party.code", category: "third_party", description: "Código único del tercero (TER-YYYY-NNNNN)" },
  221. { name: "Identificacion del Tercero", key: "third_party.identification_number", category: "third_party", description: "Número de identificación (NIT, CC, etc.)" },
  222. { name: "Tipo de Identificacion del Tercero", key: "third_party.identification_type", category: "third_party", description: "Tipo de documento del tercero" },
  223. { name: "Identificacion Completa del Tercero", key: "third_party.full_identification", category: "third_party", description: "Tipo y número de identificación" },
  224. { name: "Tipo de Tercero", key: "third_party.third_party_type", category: "third_party", description: "Proveedor, cliente, contratista, etc." },
  225. { name: "Tipo de Persona", key: "third_party.person_type", category: "third_party", description: "Natural o Jurídica" },
  226. { name: "Email del Tercero", key: "third_party.email", category: "third_party", description: "Correo electrónico del tercero" },
  227. { name: "Telefono del Tercero", key: "third_party.phone", category: "third_party", description: "Teléfono de contacto" },
  228. { name: "Direccion del Tercero", key: "third_party.address", category: "third_party", description: "Dirección del tercero" },
  229. { name: "Ciudad del Tercero", key: "third_party.city", category: "third_party", description: "Ciudad de ubicación" },
  230. { name: "Pais del Tercero", key: "third_party.country", category: "third_party", description: "País de ubicación" },
  231. { name: "Representante Legal", key: "third_party.legal_rep_name", category: "third_party", description: "Nombre del representante legal" },
  232. { name: "Cedula Representante Legal", key: "third_party.legal_rep_id", category: "third_party", description: "Cédula del representante legal" },
  233. { name: "Email Representante Legal", key: "third_party.legal_rep_email", category: "third_party", description: "Email del representante legal" },
  234. { name: "Banco del Tercero", key: "third_party.bank_name", category: "third_party", description: "Nombre del banco" },
  235. { name: "Tipo de Cuenta Bancaria", key: "third_party.bank_account_type", category: "third_party", description: "Ahorros o Corriente" },
  236. { name: "Numero de Cuenta Bancaria", key: "third_party.bank_account_number", category: "third_party", description: "Número de cuenta" },
  237. { name: "Industria del Tercero", key: "third_party.industry", category: "third_party", description: "Sector o industria" },
  238. # Contract mappings (Contratos - Módulo Legal)
  239. { name: "Numero de Contrato", key: "contract.contract_number", category: "contract", description: "Número único del contrato (CON-YYYY-NNNNN)" },
  240. { name: "Titulo del Contrato", key: "contract.title", category: "contract", description: "Título o nombre del contrato" },
  241. { name: "Descripcion del Contrato", key: "contract.description", category: "contract", description: "Descripción del objeto del contrato" },
  242. { name: "Tipo de Contrato Comercial", key: "contract.contract_type", category: "contract", description: "Servicios, compraventa, NDA, etc." },
  243. { name: "Estado del Contrato", key: "contract.status", category: "contract", description: "Estado actual del contrato" },
  244. { name: "Monto del Contrato", key: "contract.amount", category: "contract", data_type: "number", description: "Valor monetario del contrato" },
  245. { name: "Monto en Letras", key: "contract.amount_text", category: "contract", description: "Monto del contrato en palabras" },
  246. { name: "Moneda del Contrato", key: "contract.currency", category: "contract", description: "Moneda (COP, USD, EUR)" },
  247. { name: "Fecha de Inicio Vigencia", key: "contract.start_date", category: "contract", data_type: "date", description: "Fecha de inicio de vigencia del contrato comercial" },
  248. { name: "Fecha de Inicio en Texto", key: "contract.start_date_text", category: "contract", description: "Fecha de inicio en formato largo" },
  249. { name: "Fecha de Fin del Contrato", key: "contract.end_date", category: "contract", data_type: "date", description: "Fecha de terminación del contrato" },
  250. { name: "Fecha de Fin en Texto", key: "contract.end_date_text", category: "contract", description: "Fecha de fin en formato largo" },
  251. { name: "Duracion del Contrato en Dias", key: "contract.duration_days", category: "contract", data_type: "number", description: "Días de duración" },
  252. { name: "Duracion del Contrato", key: "contract.duration_text", category: "contract", description: "Duración en texto (meses, años)" },
  253. { name: "Condiciones de Pago", key: "contract.payment_terms", category: "contract", description: "Términos de pago acordados" },
  254. { name: "Frecuencia de Pago", key: "contract.payment_frequency", category: "contract", description: "Mensual, quincenal, único, etc." },
  255. { name: "Nivel de Aprobacion", key: "contract.approval_level", category: "contract", description: "Nivel requerido de aprobación" },
  256. { name: "Fecha de Aprobacion", key: "contract.approved_at", category: "contract", data_type: "date", description: "Fecha en que fue aprobado" },
  257. { name: "Fecha de Aprobacion en Texto", key: "contract.approved_at_text", category: "contract", description: "Fecha de aprobación en formato largo" }
  258. ]
  259. end
  260. end
  261. private
  262. def normalize_name
  263. return if name.blank?
  264. self.name = VariableNormalizer.normalize(name)
  265. end
  266. def generate_key
  267. base = VariableNormalizer.to_key(name)
  268. self.key = "custom.#{base}"
  269. end
  270. def key_format_valid
  271. return if key.blank?
  272. unless key.match?(/\A[a-z_]+\.[a-z_]+\z/)
  273. errors.add(:key, "debe tener formato 'categoria.campo' (solo letras minúsculas y guiones bajos)")
  274. end
  275. end
  276. def resolve_from_source(context)
  277. return nil unless source_model && source_field
  278. # Get the source object from context
  279. source = case source_model
  280. when "Hr::Employee"
  281. context[:employee]
  282. when "Identity::Organization"
  283. context[:organization]
  284. else
  285. nil
  286. end
  287. return nil unless source
  288. source.try(source_field)
  289. end
  290. def resolve_from_path(context)
  291. # Delegate to VariableResolverService
  292. VariableResolverService.new(context).resolve(key)
  293. end
  294. end
  295. end

app/models/workflow/workflow_definition.rb

0.0% lines covered

137 relevant lines. 0 lines covered and 137 lines missed.
    
  1. # frozen_string_literal: true
  2. module Workflow
  3. # Defines a workflow template that can be instantiated
  4. # Contains the state machine configuration and step definitions
  5. #
  6. # Example: Contract Approval Workflow
  7. # states: draft -> legal_review -> approved/rejected
  8. #
  9. class WorkflowDefinition
  10. include Mongoid::Document
  11. include Mongoid::Timestamps
  12. include UuidIdentifiable
  13. store_in collection: "workflow_definitions"
  14. # Fields
  15. field :name, type: String
  16. field :description, type: String
  17. field :version, type: Integer, default: 1
  18. field :active, type: Boolean, default: true
  19. field :document_type, type: String # Type of document this workflow applies to
  20. # State machine configuration
  21. field :initial_state, type: String
  22. field :states, type: Array, default: [] # Array of state names
  23. field :final_states, type: Array, default: [] # Terminal states
  24. field :transitions, type: Array, default: [] # Allowed transitions
  25. # Step definitions with role assignments and SLAs
  26. # Format: { state: "legal_review", assigned_role: "legal", sla_hours: 48, ... }
  27. field :steps, type: Hash, default: {}
  28. # Default SLA in hours if not specified per step
  29. field :default_sla_hours, type: Integer, default: 24
  30. # Indexes
  31. index({ uuid: 1 }, { unique: true })
  32. index({ name: 1, version: 1 }, { unique: true })
  33. index({ document_type: 1 })
  34. index({ active: 1 })
  35. # Associations
  36. belongs_to :organization, class_name: "Identity::Organization", optional: true
  37. has_many :instances, class_name: "Workflow::WorkflowInstance", inverse_of: :definition
  38. # Validations
  39. validates :name, presence: true
  40. validates :initial_state, presence: true
  41. validates :states, presence: true
  42. validate :initial_state_in_states
  43. validate :final_states_in_states
  44. validate :transitions_valid
  45. # Scopes
  46. scope :active, -> { where(active: true) }
  47. scope :for_document_type, ->(type) { where(document_type: type) }
  48. scope :latest_versions, -> { where(active: true).order(version: :desc) }
  49. # Check if a transition is allowed
  50. def transition_allowed?(from_state, to_state)
  51. transitions.any? do |t|
  52. t["from"] == from_state && t["to"] == to_state
  53. end
  54. end
  55. # Get available transitions from a state
  56. def available_transitions(from_state)
  57. transitions.select { |t| t["from"] == from_state }.pluck("to")
  58. end
  59. # Get step configuration for a state
  60. def step_for(state)
  61. steps[state] || {}
  62. end
  63. # Get assigned role for a state
  64. def assigned_role_for(state)
  65. step_for(state)["assigned_role"]
  66. end
  67. # Get SLA hours for a state
  68. def sla_hours_for(state)
  69. step_for(state)["sla_hours"] || default_sla_hours
  70. end
  71. # Check if state is final
  72. def final_state?(state)
  73. final_states.include?(state)
  74. end
  75. # Create a new instance of this workflow
  76. def create_instance!(document:, initiated_by:)
  77. WorkflowInstance.create!(
  78. definition: self,
  79. document: document,
  80. organization: organization || document.organization,
  81. current_state: initial_state,
  82. initiated_by: initiated_by,
  83. started_at: Time.current
  84. )
  85. end
  86. # Create a new version of this definition
  87. def create_new_version!
  88. new_def = dup
  89. new_def.uuid = nil # Clear UUID so a new one is generated
  90. new_def.version = version + 1
  91. new_def.save!
  92. # Deactivate old version
  93. update!(active: false)
  94. new_def
  95. end
  96. private
  97. def initial_state_in_states
  98. return if states.include?(initial_state)
  99. errors.add(:initial_state, "must be one of the defined states")
  100. end
  101. def final_states_in_states
  102. invalid = final_states - states
  103. return if invalid.empty?
  104. errors.add(:final_states, "contains invalid states: #{invalid.join(", ")}")
  105. end
  106. def transitions_valid
  107. transitions.each do |t|
  108. unless t["from"].present? && t["to"].present?
  109. errors.add(:transitions, "must have 'from' and 'to' states")
  110. next
  111. end
  112. unless states.include?(t["from"]) && states.include?(t["to"])
  113. errors.add(:transitions, "contains invalid states in transition: #{t["from"]} -> #{t["to"]}")
  114. end
  115. end
  116. end
  117. class << self
  118. # Find the latest active version of a workflow by name
  119. def find_latest(name)
  120. active.where(name: name).order(version: :desc).first
  121. end
  122. # Seed the contract approval workflow
  123. # rubocop:disable Metrics/MethodLength, Metrics/BlockLength
  124. def seed_contract_approval!
  125. find_or_create_by!(name: "contract_approval", version: 1) do |w|
  126. w.description = "Standard contract approval workflow with legal review"
  127. w.document_type = "contract"
  128. w.initial_state = "draft"
  129. w.states = ["draft", "legal_review", "approved", "rejected"]
  130. w.final_states = ["approved", "rejected"]
  131. w.transitions = [
  132. { "from" => "draft", "to" => "legal_review", "action" => "submit_for_review" },
  133. { "from" => "legal_review", "to" => "approved", "action" => "approve" },
  134. { "from" => "legal_review", "to" => "rejected", "action" => "reject" },
  135. { "from" => "legal_review", "to" => "draft", "action" => "request_changes" },
  136. { "from" => "rejected", "to" => "draft", "action" => "revise" }
  137. ]
  138. w.steps = {
  139. "draft" => {
  140. "assigned_role" => "employee",
  141. "sla_hours" => nil, # No SLA for draft
  142. "description" => "Initial draft creation"
  143. },
  144. "legal_review" => {
  145. "assigned_role" => "legal",
  146. "sla_hours" => 48,
  147. "description" => "Legal team review and approval"
  148. },
  149. "approved" => {
  150. "assigned_role" => nil,
  151. "sla_hours" => nil,
  152. "description" => "Contract approved"
  153. },
  154. "rejected" => {
  155. "assigned_role" => nil,
  156. "sla_hours" => nil,
  157. "description" => "Contract rejected"
  158. }
  159. }
  160. w.default_sla_hours = 24
  161. end
  162. end
  163. # rubocop:enable Metrics/MethodLength, Metrics/BlockLength
  164. end
  165. end
  166. end

app/models/workflow/workflow_instance.rb

0.0% lines covered

217 relevant lines. 0 lines covered and 217 lines missed.
    
  1. # frozen_string_literal: true
  2. module Workflow
  3. # Represents a running instance of a workflow
  4. # Tracks the current state, history, and progress through the workflow
  5. #
  6. # Example: A specific contract going through the approval workflow
  7. #
  8. # rubocop:disable Metrics/ClassLength
  9. class WorkflowInstance
  10. include Mongoid::Document
  11. include Mongoid::Timestamps
  12. include UuidIdentifiable
  13. store_in collection: "workflow_instances"
  14. # Status constants
  15. STATUS_ACTIVE = "active"
  16. STATUS_COMPLETED = "completed"
  17. STATUS_CANCELLED = "cancelled"
  18. STATUS_SUSPENDED = "suspended"
  19. STATUSES = [STATUS_ACTIVE, STATUS_COMPLETED, STATUS_CANCELLED, STATUS_SUSPENDED].freeze
  20. # Fields
  21. field :current_state, type: String
  22. field :status, type: String, default: STATUS_ACTIVE
  23. field :started_at, type: Time
  24. field :completed_at, type: Time
  25. field :cancelled_at, type: Time
  26. field :cancellation_reason, type: String
  27. # State history for audit trail
  28. # Format: [{ from: "draft", to: "review", action: "submit", actor_id: "...", at: Time, comment: "..." }]
  29. field :state_history, type: Array, default: []
  30. # Custom data associated with this instance
  31. field :context_data, type: Hash, default: {}
  32. # Indexes
  33. index({ uuid: 1 }, { unique: true })
  34. index({ status: 1 })
  35. index({ current_state: 1 })
  36. index({ organization_id: 1, status: 1 })
  37. index({ document_id: 1 })
  38. index({ started_at: -1 })
  39. # Associations
  40. belongs_to :definition, class_name: "Workflow::WorkflowDefinition", inverse_of: :instances
  41. belongs_to :document, class_name: "Content::Document", optional: true
  42. belongs_to :organization, class_name: "Identity::Organization"
  43. belongs_to :initiated_by, class_name: "Identity::User"
  44. has_many :tasks, class_name: "Workflow::WorkflowTask", inverse_of: :instance, dependent: :destroy
  45. # Validations
  46. validates :current_state, presence: true
  47. validates :status, presence: true, inclusion: { in: STATUSES }
  48. validate :current_state_valid
  49. # Scopes
  50. scope :active, -> { where(status: STATUS_ACTIVE) }
  51. scope :completed, -> { where(status: STATUS_COMPLETED) }
  52. scope :cancelled, -> { where(status: STATUS_CANCELLED) }
  53. scope :suspended, -> { where(status: STATUS_SUSPENDED) }
  54. scope :for_document, ->(doc) { where(document_id: doc.is_a?(BSON::ObjectId) ? doc : doc.id) }
  55. scope :in_state, ->(state) { where(current_state: state) }
  56. # Callbacks
  57. after_create :create_initial_task
  58. after_create :record_workflow_started
  59. # Check if workflow is active
  60. def active?
  61. status == STATUS_ACTIVE
  62. end
  63. # Check if workflow is completed
  64. def completed?
  65. status == STATUS_COMPLETED
  66. end
  67. # Check if workflow is in a final state
  68. def in_final_state?
  69. definition.final_state?(current_state)
  70. end
  71. # Get available transitions from current state
  72. def available_transitions
  73. definition.available_transitions(current_state)
  74. end
  75. # Check if a transition is allowed
  76. def can_transition_to?(to_state)
  77. return false unless active?
  78. definition.transition_allowed?(current_state, to_state)
  79. end
  80. # Perform a state transition
  81. # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
  82. def transition_to!(to_state, actor:, action: nil, comment: nil)
  83. raise WorkflowError, "Workflow is not active" unless active?
  84. raise WorkflowError, "Transition not allowed: #{current_state} -> #{to_state}" unless can_transition_to?(to_state)
  85. from_state = current_state
  86. # Complete the task for the current (from) state BEFORE changing state
  87. complete_task_for_state!(from_state, actor, comment)
  88. # Record transition in history
  89. state_history << {
  90. "from" => from_state,
  91. "to" => to_state,
  92. "action" => action || find_action_for_transition(from_state, to_state),
  93. "actor_id" => actor.id.to_s,
  94. "actor_name" => actor.full_name,
  95. "at" => Time.current.iso8601,
  96. "comment" => comment
  97. }.compact
  98. # Update current state
  99. self.current_state = to_state
  100. # Check if we've reached a final state
  101. if definition.final_state?(to_state)
  102. self.status = STATUS_COMPLETED
  103. self.completed_at = Time.current
  104. else
  105. # Create task for next state
  106. create_task_for_state!(to_state)
  107. end
  108. save!
  109. # Fire notification job
  110. notify_transition(from_state, to_state, actor)
  111. self
  112. end
  113. # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
  114. # Cancel the workflow
  115. def cancel!(actor:, reason: nil)
  116. raise WorkflowError, "Workflow is not active" unless active?
  117. self.status = STATUS_CANCELLED
  118. self.cancelled_at = Time.current
  119. self.cancellation_reason = reason
  120. state_history << {
  121. "from" => current_state,
  122. "to" => "cancelled",
  123. "action" => "cancel",
  124. "actor_id" => actor.id.to_s,
  125. "actor_name" => actor.full_name,
  126. "at" => Time.current.iso8601,
  127. "comment" => reason
  128. }.compact
  129. # Cancel any active tasks (pending or in_progress)
  130. # rubocop:disable Rails/FindEach
  131. Workflow::WorkflowTask.active.where(instance_id: id).each { |task| task.cancel!(actor) }
  132. # rubocop:enable Rails/FindEach
  133. save!
  134. notify_cancellation(actor, reason)
  135. self
  136. end
  137. # Suspend the workflow
  138. def suspend!(actor:, reason: nil)
  139. raise WorkflowError, "Workflow is not active" unless active?
  140. self.status = STATUS_SUSPENDED
  141. state_history << {
  142. "from" => current_state,
  143. "to" => current_state,
  144. "action" => "suspend",
  145. "actor_id" => actor.id.to_s,
  146. "actor_name" => actor.full_name,
  147. "at" => Time.current.iso8601,
  148. "comment" => reason
  149. }.compact
  150. save!
  151. self
  152. end
  153. # Resume a suspended workflow
  154. def resume!(actor:)
  155. raise WorkflowError, "Workflow is not suspended" unless status == STATUS_SUSPENDED
  156. self.status = STATUS_ACTIVE
  157. state_history << {
  158. "from" => current_state,
  159. "to" => current_state,
  160. "action" => "resume",
  161. "actor_id" => actor.id.to_s,
  162. "actor_name" => actor.full_name,
  163. "at" => Time.current.iso8601
  164. }
  165. save!
  166. self
  167. end
  168. # Get the current active task (pending or in progress)
  169. def current_task
  170. tasks.active.where(state: current_state).first
  171. end
  172. # Get step configuration for current state
  173. def current_step
  174. definition.step_for(current_state)
  175. end
  176. # Get assigned role for current state
  177. def current_assigned_role
  178. definition.assigned_role_for(current_state)
  179. end
  180. # Duration of the workflow so far
  181. def duration
  182. end_time = completed_at || cancelled_at || Time.current
  183. end_time - started_at
  184. end
  185. # Duration in a specific state
  186. def time_in_state(state)
  187. entries = state_history.select { |h| h["to"] == state }
  188. exits = state_history.select { |h| h["from"] == state }
  189. total = 0
  190. entries.each_with_index do |entry, i|
  191. exit_record = exits[i]
  192. entry_time = Time.zone.parse(entry["at"])
  193. exit_time = exit_record ? Time.zone.parse(exit_record["at"]) : Time.current
  194. total += (exit_time - entry_time)
  195. end
  196. total
  197. end
  198. private
  199. def current_state_valid
  200. return if definition.blank?
  201. return if definition.states.include?(current_state)
  202. errors.add(:current_state, "is not a valid state for this workflow")
  203. end
  204. def create_initial_task
  205. create_task_for_state!(current_state)
  206. end
  207. def create_task_for_state!(state)
  208. step = definition.step_for(state)
  209. sla_hours = definition.sla_hours_for(state)
  210. tasks.create!(
  211. state: state,
  212. assigned_role: step["assigned_role"],
  213. organization: organization,
  214. due_at: sla_hours ? Time.current + sla_hours.hours : nil,
  215. sla_hours: sla_hours
  216. )
  217. end
  218. def complete_task_for_state!(state, actor, comment)
  219. task = tasks.active.where(state: state).first
  220. task&.complete!(actor, comment)
  221. end
  222. def find_action_for_transition(from, to)
  223. transition = definition.transitions.find { |t| t["from"] == from && t["to"] == to }
  224. transition&.dig("action")
  225. end
  226. def record_workflow_started
  227. Audit::AuditEvent.log(
  228. event_type: Audit::AuditEvent::TYPES[:workflow],
  229. action: "workflow_started",
  230. target: self,
  231. actor: initiated_by,
  232. metadata: {
  233. workflow_name: definition.name,
  234. initial_state: current_state,
  235. document_id: document_id&.to_s
  236. },
  237. tags: ["workflow", "started"]
  238. )
  239. end
  240. def notify_transition(from_state, to_state, actor)
  241. WorkflowNotificationJob.perform_later(
  242. "transition",
  243. id.to_s,
  244. from_state: from_state,
  245. to_state: to_state,
  246. actor_id: actor.id.to_s
  247. )
  248. end
  249. def notify_cancellation(actor, reason)
  250. WorkflowNotificationJob.perform_later(
  251. "cancelled",
  252. id.to_s,
  253. actor_id: actor.id.to_s,
  254. reason: reason
  255. )
  256. end
  257. end
  258. # rubocop:enable Metrics/ClassLength
  259. # Custom workflow error
  260. class WorkflowError < StandardError; end
  261. end

app/models/workflow/workflow_task.rb

0.0% lines covered

203 relevant lines. 0 lines covered and 203 lines missed.
    
  1. # frozen_string_literal: true
  2. module Workflow
  3. # Represents a task within a workflow instance
  4. # Tracks assignment, SLA, and completion of individual workflow steps
  5. #
  6. # Tasks are created when a workflow enters a new state and must be
  7. # completed to advance the workflow
  8. #
  9. # rubocop:disable Metrics/ClassLength
  10. class WorkflowTask
  11. include Mongoid::Document
  12. include Mongoid::Timestamps
  13. include UuidIdentifiable
  14. store_in collection: "workflow_tasks"
  15. # Status constants
  16. STATUS_PENDING = "pending"
  17. STATUS_IN_PROGRESS = "in_progress"
  18. STATUS_COMPLETED = "completed"
  19. STATUS_CANCELLED = "cancelled"
  20. STATUS_OVERDUE = "overdue"
  21. STATUSES = [STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_COMPLETED, STATUS_CANCELLED, STATUS_OVERDUE].freeze
  22. # Fields
  23. field :state, type: String # The workflow state this task is for
  24. field :status, type: String, default: STATUS_PENDING
  25. field :assigned_role, type: String # Role that can complete this task
  26. field :sla_hours, type: Integer
  27. field :due_at, type: Time
  28. field :started_at, type: Time
  29. field :completed_at, type: Time
  30. field :cancelled_at, type: Time
  31. field :completion_comment, type: String
  32. field :priority, type: Integer, default: 0 # Higher = more urgent
  33. # Escalation tracking
  34. field :escalation_level, type: Integer, default: 0
  35. field :last_escalated_at, type: Time
  36. field :escalation_history, type: Array, default: []
  37. # Indexes
  38. index({ uuid: 1 }, { unique: true })
  39. index({ status: 1 })
  40. index({ assigned_role: 1 })
  41. index({ due_at: 1 })
  42. index({ organization_id: 1, status: 1 })
  43. index({ instance_id: 1, state: 1 })
  44. index({ assignee_id: 1, status: 1 })
  45. # Associations
  46. belongs_to :instance, class_name: "Workflow::WorkflowInstance", inverse_of: :tasks
  47. belongs_to :organization, class_name: "Identity::Organization"
  48. belongs_to :assignee, class_name: "Identity::User", optional: true
  49. belongs_to :completed_by, class_name: "Identity::User", optional: true
  50. # Validations
  51. validates :state, presence: true
  52. validates :status, presence: true, inclusion: { in: STATUSES }
  53. # Scopes
  54. scope :pending, -> { where(status: STATUS_PENDING) }
  55. scope :in_progress, -> { where(status: STATUS_IN_PROGRESS) }
  56. scope :completed, -> { where(status: STATUS_COMPLETED) }
  57. scope :active, -> { where(:status.in => [STATUS_PENDING, STATUS_IN_PROGRESS]) }
  58. scope :overdue, -> { where(:due_at.lt => Time.current, :status.in => [STATUS_PENDING, STATUS_IN_PROGRESS]) }
  59. scope :due_soon, ->(hours = 4) { where(:due_at.lte => Time.current + hours.hours, :due_at.gt => Time.current) }
  60. scope :for_role, ->(role) { where(assigned_role: role) }
  61. scope :for_user, ->(user) { where(assignee_id: user.id) }
  62. scope :by_priority, -> { order(priority: :desc, due_at: :asc) }
  63. # Callbacks
  64. after_create :schedule_sla_check
  65. after_create :notify_assigned_role
  66. # Check if task is pending
  67. def pending?
  68. status == STATUS_PENDING
  69. end
  70. # Check if task is in progress
  71. def in_progress?
  72. status == STATUS_IN_PROGRESS
  73. end
  74. # Check if task is completed
  75. def completed?
  76. status == STATUS_COMPLETED
  77. end
  78. # Check if task is overdue
  79. def overdue?
  80. return false if due_at.blank?
  81. return false if completed? || status == STATUS_CANCELLED
  82. Time.current > due_at
  83. end
  84. # Time remaining until due
  85. def time_remaining
  86. return nil if due_at.blank?
  87. return 0 if overdue?
  88. due_at - Time.current
  89. end
  90. # Time remaining as human readable
  91. # rubocop:disable Metrics/PerceivedComplexity
  92. def time_remaining_text
  93. remaining = time_remaining
  94. return "Overdue" if remaining.nil? || remaining <= 0
  95. hours = (remaining / 1.hour).floor
  96. if hours >= 24
  97. days = (hours / 24).floor
  98. "#{days} day#{"s" unless days == 1}"
  99. elsif hours >= 1
  100. "#{hours} hour#{"s" unless hours == 1}"
  101. else
  102. minutes = (remaining / 1.minute).floor
  103. "#{minutes} minute#{"s" unless minutes == 1}"
  104. end
  105. end
  106. # rubocop:enable Metrics/PerceivedComplexity
  107. # Claim the task for a specific user
  108. def claim!(user)
  109. raise WorkflowError, "Task is not pending" unless pending?
  110. raise WorkflowError, "User does not have required role" unless user_has_role?(user)
  111. self.assignee = user
  112. self.status = STATUS_IN_PROGRESS
  113. self.started_at = Time.current
  114. save!
  115. record_audit_event("task_claimed", user)
  116. self
  117. end
  118. # Release a claimed task back to the pool
  119. def release!(user)
  120. raise WorkflowError, "Task is not in progress" unless in_progress?
  121. raise WorkflowError, "Only assignee can release task" unless assignee_id == user.id
  122. self.assignee = nil
  123. self.status = STATUS_PENDING
  124. self.started_at = nil
  125. save!
  126. record_audit_event("task_released", user)
  127. self
  128. end
  129. # Complete the task
  130. def complete!(actor, comment = nil)
  131. raise WorkflowError, "Task cannot be completed" unless can_complete?
  132. self.status = STATUS_COMPLETED
  133. self.completed_at = Time.current
  134. self.completed_by = actor
  135. self.completion_comment = comment
  136. save!
  137. record_audit_event("task_completed", actor)
  138. self
  139. end
  140. # Cancel the task
  141. def cancel!(actor)
  142. return if status == STATUS_CANCELLED
  143. self.status = STATUS_CANCELLED
  144. self.cancelled_at = Time.current
  145. save!
  146. record_audit_event("task_cancelled", actor)
  147. self
  148. end
  149. # Check if task can be completed
  150. def can_complete?
  151. pending? || in_progress?
  152. end
  153. # Check if user can work on this task
  154. def user_can_work?(user)
  155. return false unless can_complete?
  156. return true if assignee_id == user.id
  157. pending? && user_has_role?(user)
  158. end
  159. # Escalate the task
  160. def escalate!(reason: nil)
  161. self.escalation_level += 1
  162. self.last_escalated_at = Time.current
  163. self.priority += 10
  164. escalation_history << {
  165. "level" => escalation_level,
  166. "at" => Time.current.iso8601,
  167. "reason" => reason
  168. }.compact
  169. save!
  170. WorkflowNotificationJob.perform_later(
  171. "task_escalated",
  172. id.to_s,
  173. escalation_level: escalation_level,
  174. reason: reason
  175. )
  176. self
  177. end
  178. # Get users who can work on this task
  179. def eligible_users
  180. return Identity::User.none if assigned_role.blank?
  181. organization.users.joins(:roles).where(
  182. identity_roles: { name: assigned_role }
  183. )
  184. end
  185. # Duration of the task (started to completed or now)
  186. def duration
  187. return nil unless started_at
  188. end_time = completed_at || Time.current
  189. end_time - started_at
  190. end
  191. # Check SLA compliance
  192. def sla_compliant?
  193. return true if due_at.blank?
  194. if completed?
  195. completed_at <= due_at
  196. else
  197. Time.current <= due_at
  198. end
  199. end
  200. private
  201. def user_has_role?(user)
  202. return true if assigned_role.blank?
  203. user.roles.exists?(name: assigned_role)
  204. end
  205. # rubocop:disable Metrics/AbcSize, Naming/VariableNumber
  206. def schedule_sla_check
  207. return if due_at.blank?
  208. # Schedule a job to check SLA at the due time
  209. SlaCheckJob.set(wait_until: due_at).perform_later(id.to_s)
  210. # Also schedule warning notifications at 75% and 50% of SLA
  211. return unless sla_hours && sla_hours > 2
  212. warning_time_75 = created_at + (sla_hours * 0.75).hours
  213. warning_time_50 = created_at + (sla_hours * 0.5).hours
  214. SlaWarningJob.set(wait_until: warning_time_75).perform_later(id.to_s, 75)
  215. SlaWarningJob.set(wait_until: warning_time_50).perform_later(id.to_s, 50) if sla_hours > 8
  216. end
  217. # rubocop:enable Metrics/AbcSize, Naming/VariableNumber
  218. def notify_assigned_role
  219. WorkflowNotificationJob.perform_later(
  220. "task_created",
  221. id.to_s,
  222. assigned_role: assigned_role,
  223. state: state
  224. )
  225. end
  226. def record_audit_event(action, actor)
  227. Audit::AuditEvent.log(
  228. event_type: Audit::AuditEvent::TYPES[:workflow],
  229. action: action,
  230. target: self,
  231. actor: actor,
  232. metadata: {
  233. workflow_instance_id: instance_id.to_s,
  234. state: state,
  235. assigned_role: assigned_role
  236. },
  237. tags: ["workflow", "task", action]
  238. )
  239. end
  240. end
  241. # rubocop:enable Metrics/ClassLength
  242. end

app/policies/application_policy.rb

0.0% lines covered

68 relevant lines. 0 lines covered and 68 lines missed.
    
  1. # frozen_string_literal: true
  2. class ApplicationPolicy
  3. attr_reader :user, :record
  4. def initialize(user, record)
  5. @user = user
  6. @record = record
  7. end
  8. def index?
  9. false
  10. end
  11. def show?
  12. false
  13. end
  14. def create?
  15. false
  16. end
  17. def new?
  18. create?
  19. end
  20. def update?
  21. false
  22. end
  23. def edit?
  24. update?
  25. end
  26. def destroy?
  27. false
  28. end
  29. class Scope
  30. attr_reader :user, :scope
  31. def initialize(user, scope)
  32. @user = user
  33. @scope = scope
  34. end
  35. def resolve
  36. raise NotImplementedError, "You must define #resolve in #{self.class}"
  37. end
  38. private
  39. def admin?
  40. user&.admin?
  41. end
  42. def super_admin?
  43. user&.super_admin?
  44. end
  45. end
  46. private
  47. def admin?
  48. user&.admin?
  49. end
  50. def super_admin?
  51. user&.super_admin?
  52. end
  53. def owner?
  54. return false unless record.respond_to?(:created_by_id)
  55. record.created_by_id == user&.id
  56. end
  57. def same_organization?
  58. return false unless user&.organization_id && record.respond_to?(:organization_id)
  59. user.organization_id == record.organization_id
  60. end
  61. def has_permission?(permission_name)
  62. return false unless user
  63. user.has_permission?(permission_name)
  64. end
  65. def has_role?(role_name)
  66. return false unless user
  67. user.has_role?(role_name)
  68. end
  69. end

app/policies/content/document_policy.rb

0.0% lines covered

42 relevant lines. 0 lines covered and 42 lines missed.
    
  1. # frozen_string_literal: true
  2. module Content
  3. class DocumentPolicy < ApplicationPolicy
  4. def index?
  5. has_permission?("documents.read")
  6. end
  7. def show?
  8. has_permission?("documents.read") && same_organization?
  9. end
  10. def create?
  11. has_permission?("documents.create")
  12. end
  13. def update?
  14. return true if admin?
  15. return false unless has_permission?("documents.update")
  16. return false unless same_organization?
  17. # Check if user can update (owner or has manage permission)
  18. owner? || has_permission?("documents.manage")
  19. end
  20. def destroy?
  21. return true if admin?
  22. return false unless has_permission?("documents.delete")
  23. return false unless same_organization?
  24. owner? || has_permission?("documents.manage")
  25. end
  26. class Scope < ApplicationPolicy::Scope
  27. def resolve
  28. if admin?
  29. scope.all
  30. elsif user&.organization_id
  31. scope.by_organization(user.organization_id)
  32. else
  33. scope.none
  34. end
  35. end
  36. end
  37. private
  38. def same_organization?
  39. return true if admin?
  40. return true unless record.organization_id
  41. record.organization_id == user&.organization_id
  42. end
  43. end
  44. end

app/policies/content/folder_policy.rb

0.0% lines covered

38 relevant lines. 0 lines covered and 38 lines missed.
    
  1. # frozen_string_literal: true
  2. module Content
  3. class FolderPolicy < ApplicationPolicy
  4. def index?
  5. has_permission?("documents.read")
  6. end
  7. def show?
  8. has_permission?("documents.read") && same_organization?
  9. end
  10. def create?
  11. has_permission?("documents.create")
  12. end
  13. def update?
  14. return true if admin?
  15. has_permission?("documents.update") && same_organization?
  16. end
  17. def destroy?
  18. return true if admin?
  19. has_permission?("documents.delete") && same_organization?
  20. end
  21. class Scope < ApplicationPolicy::Scope
  22. def resolve
  23. if admin?
  24. scope.all
  25. elsif user&.organization_id
  26. scope.by_organization(user.organization_id)
  27. else
  28. scope.none
  29. end
  30. end
  31. end
  32. private
  33. def same_organization?
  34. return true if admin?
  35. return true unless record.organization_id
  36. record.organization_id == user&.organization_id
  37. end
  38. end
  39. end

app/policies/hr/employee_policy.rb

0.0% lines covered

59 relevant lines. 0 lines covered and 59 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. class EmployeePolicy < ApplicationPolicy
  4. def index?
  5. hr_staff? || supervisor?
  6. end
  7. def show?
  8. owner? || hr_staff? || supervisor_of_record?
  9. end
  10. def create?
  11. hr_staff? || admin?
  12. end
  13. def update?
  14. hr_staff? || admin?
  15. end
  16. def create_account?
  17. hr_staff? || admin?
  18. end
  19. def show_balance?
  20. owner? || hr_staff? || supervisor_of_record?
  21. end
  22. class Scope < ApplicationPolicy::Scope
  23. def resolve
  24. if user_employee.hr_staff? || user_employee.hr_manager?
  25. scope.where(organization_id: user.organization_id)
  26. elsif user_employee.supervisor?
  27. # Supervisors see their subordinates plus themselves
  28. scope.or(
  29. { id: user_employee.id },
  30. { supervisor_id: user_employee.id }
  31. )
  32. else
  33. scope.where(id: user_employee.id)
  34. end
  35. end
  36. private
  37. def user_employee
  38. @user_employee ||= ::Hr::Employee.for_user(user)
  39. end
  40. end
  41. private
  42. def owner?
  43. record.id == user_employee&.id
  44. end
  45. def hr_staff?
  46. user_employee&.hr_staff? || user_employee&.hr_manager?
  47. end
  48. def supervisor?
  49. user_employee&.supervisor?
  50. end
  51. def supervisor_of_record?
  52. user_employee&.supervises?(record)
  53. end
  54. def admin?
  55. user&.admin?
  56. end
  57. def user_employee
  58. @user_employee ||= ::Hr::Employee.for_user(user)
  59. end
  60. end
  61. end

app/policies/hr/employment_certification_request_policy.rb

0.0% lines covered

58 relevant lines. 0 lines covered and 58 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. class EmploymentCertificationRequestPolicy < ApplicationPolicy
  4. def index?
  5. true # All authenticated users can list their own
  6. end
  7. def show?
  8. owner? || hr_staff? || supervisor_of_owner?
  9. end
  10. def create?
  11. true # All authenticated employees can create
  12. end
  13. def update?
  14. owner? && record.pending?
  15. end
  16. def cancel?
  17. return false if record.completed? || record.rejected?
  18. hr_staff? || owner?
  19. end
  20. def generate_document?
  21. hr_staff? # Only HR can generate documents
  22. end
  23. def sign_document?
  24. hr_staff? || admin? # HR and Admin can sign documents
  25. end
  26. def destroy?
  27. admin? # Only admin can delete certifications
  28. end
  29. class Scope < ApplicationPolicy::Scope
  30. def resolve
  31. if user_employee.hr_staff? || user_employee.hr_manager?
  32. scope.where(organization_id: user.organization_id)
  33. else
  34. scope.where(employee_id: user_employee.id)
  35. end
  36. end
  37. private
  38. def user_employee
  39. @user_employee ||= ::Hr::Employee.for_user(user)
  40. end
  41. end
  42. private
  43. def owner?
  44. record.employee_id == user_employee&.id
  45. end
  46. def hr_staff?
  47. user_employee&.hr_staff? || user_employee&.hr_manager?
  48. end
  49. def supervisor_of_owner?
  50. user_employee&.supervises?(record.employee)
  51. end
  52. def user_employee
  53. @user_employee ||= ::Hr::Employee.for_user(user)
  54. end
  55. def admin?
  56. user.has_role?(:admin)
  57. end
  58. end
  59. end

app/policies/hr/vacation_request_policy.rb

0.0% lines covered

58 relevant lines. 0 lines covered and 58 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. class VacationRequestPolicy < ApplicationPolicy
  4. def index?
  5. true # All authenticated users can list their own
  6. end
  7. def show?
  8. owner? || hr_staff? || supervisor_of_owner?
  9. end
  10. def create?
  11. true # All authenticated employees can create
  12. end
  13. def update?
  14. owner? && record.draft?
  15. end
  16. def submit?
  17. owner? && record.draft?
  18. end
  19. def cancel?
  20. return false if record.rejected?
  21. hr_manager? || (owner? && can_employee_cancel?)
  22. end
  23. def destroy?
  24. # Admin/HR can delete any request, owner can delete their own (with restrictions in controller)
  25. admin? || hr_manager? || owner?
  26. end
  27. class Scope < ApplicationPolicy::Scope
  28. def resolve
  29. if user_employee.hr_staff? || user_employee.hr_manager?
  30. scope.where(organization_id: user.organization_id)
  31. else
  32. scope.where(employee_id: user_employee.id)
  33. end
  34. end
  35. private
  36. def user_employee
  37. @user_employee ||= ::Hr::Employee.for_user(user)
  38. end
  39. end
  40. private
  41. def owner?
  42. record.employee_id == user_employee&.id
  43. end
  44. def hr_staff?
  45. user_employee&.hr_staff? || user_employee&.hr_manager?
  46. end
  47. def hr_manager?
  48. user_employee&.hr_manager?
  49. end
  50. def supervisor_of_owner?
  51. user_employee&.supervises?(record.employee)
  52. end
  53. def can_employee_cancel?
  54. record.draft? || record.pending? || (record.approved? && record.start_date > Date.current)
  55. end
  56. def user_employee
  57. @user_employee ||= ::Hr::Employee.for_user(user)
  58. end
  59. end
  60. end

app/policies/identity/user_policy.rb

0.0% lines covered

30 relevant lines. 0 lines covered and 30 lines missed.
    
  1. # frozen_string_literal: true
  2. module Identity
  3. class UserPolicy < ApplicationPolicy
  4. def index?
  5. admin? || has_permission?("users.read")
  6. end
  7. def show?
  8. admin? || has_permission?("users.read") || user == record
  9. end
  10. def create?
  11. admin? || has_permission?("users.create")
  12. end
  13. def update?
  14. admin? || has_permission?("users.update") || user == record
  15. end
  16. def destroy?
  17. admin? || has_permission?("users.delete")
  18. end
  19. class Scope < ApplicationPolicy::Scope
  20. def resolve
  21. if admin?
  22. scope.all
  23. elsif user&.has_permission?("users.read")
  24. scope.where(organization_id: user.organization_id)
  25. else
  26. scope.none
  27. end
  28. end
  29. end
  30. end
  31. end

app/policies/legal/contract_policy.rb

0.0% lines covered

108 relevant lines. 0 lines covered and 108 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class ContractPolicy < ApplicationPolicy
  4. def index?
  5. # Allow all authenticated users - scoping will filter appropriately
  6. true
  7. end
  8. def show?
  9. admin? || legal_staff? || (manager? && owner_or_approver?) || signatory?
  10. end
  11. def create?
  12. admin? || legal_staff? || manager?
  13. end
  14. def validate_template?
  15. admin? || legal_staff? || manager?
  16. end
  17. def update?
  18. return false unless record.editable?
  19. admin? || legal_staff? || (manager? && owner?)
  20. end
  21. def destroy?
  22. # Admin can delete any contract (for testing/cleanup purposes)
  23. return true if admin?
  24. # Others can only delete drafts they own
  25. return false unless record.draft?
  26. legal_staff? && owner?
  27. end
  28. def submit?
  29. return false unless record.can_submit?
  30. admin? || legal_staff? || (manager? && owner?)
  31. end
  32. def approve?
  33. return false unless record.pending_approval?
  34. record.can_approve?(user)
  35. end
  36. def reject?
  37. approve?
  38. end
  39. def activate?
  40. return false unless record.can_activate?
  41. admin? || legal_staff?
  42. end
  43. def sign_document?
  44. return false unless record.pending_signatures?
  45. doc = record.generated_document
  46. return false unless doc
  47. doc.can_be_signed_by?(user)
  48. end
  49. def terminate?
  50. return false unless record.active?
  51. admin? || legal_staff?
  52. end
  53. def cancel?
  54. return false if record.active? || record.expired? || record.terminated?
  55. admin? || legal_staff? || (manager? && owner?)
  56. end
  57. def archive?
  58. return false unless %w[active expired terminated cancelled].include?(record.status)
  59. admin?
  60. end
  61. def unarchive?
  62. return false unless record.archived?
  63. admin?
  64. end
  65. def generate_document?
  66. admin? || legal_staff?
  67. end
  68. def download_document?
  69. admin? || legal_staff? || (manager? && owner_or_approver?) || signatory?
  70. end
  71. class Scope < ApplicationPolicy::Scope
  72. def resolve
  73. if admin? || legal_staff?
  74. scope.where(organization_id: user.organization_id)
  75. elsif manager?
  76. # Managers see contracts they created, need to approve, or need to sign
  77. scope.where(organization_id: user.organization_id).any_of(
  78. { requested_by_id: user.id },
  79. { :status => "pending_approval" },
  80. { :status => "pending_signatures" }
  81. )
  82. else
  83. # Other users only see contracts where they are signatories
  84. scope.where(organization_id: user.organization_id, status: "pending_signatures")
  85. end
  86. end
  87. private
  88. def manager?
  89. user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
  90. end
  91. def legal_staff?
  92. user.has_role?("legal")
  93. end
  94. end
  95. private
  96. def legal_staff?
  97. user.has_role?("legal")
  98. end
  99. def manager?
  100. user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
  101. end
  102. def owner?
  103. record.requested_by_id == user.id
  104. end
  105. def owner_or_approver?
  106. owner? || record.can_approve?(user)
  107. end
  108. def signatory?
  109. doc = record.generated_document
  110. return false unless doc
  111. doc.can_be_signed_by?(user)
  112. end
  113. end
  114. end

app/policies/legal/third_party_policy.rb

0.0% lines covered

35 relevant lines. 0 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class ThirdPartyPolicy < ApplicationPolicy
  4. def index?
  5. admin? || legal_staff? || manager?
  6. end
  7. def show?
  8. admin? || legal_staff? || manager?
  9. end
  10. def create?
  11. admin? || legal_staff?
  12. end
  13. def update?
  14. admin? || legal_staff?
  15. end
  16. def destroy?
  17. admin?
  18. end
  19. class Scope < ApplicationPolicy::Scope
  20. def resolve
  21. if admin? || legal_staff? || manager?
  22. scope.where(organization_id: user.organization_id)
  23. else
  24. scope.none
  25. end
  26. end
  27. end
  28. private
  29. def legal_staff?
  30. user.has_role?("legal")
  31. end
  32. def manager?
  33. user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
  34. end
  35. end
  36. end

app/policies/legal/third_party_type_policy.rb

0.0% lines covered

38 relevant lines. 0 lines covered and 38 lines missed.
    
  1. # frozen_string_literal: true
  2. module Legal
  3. class ThirdPartyTypePolicy < ApplicationPolicy
  4. def index?
  5. admin? || legal_staff? || manager?
  6. end
  7. def show?
  8. admin? || legal_staff? || manager?
  9. end
  10. def create?
  11. admin? || legal_staff?
  12. end
  13. def update?
  14. admin? || legal_staff?
  15. end
  16. def destroy?
  17. admin?
  18. end
  19. def toggle_active?
  20. admin? || legal_staff?
  21. end
  22. class Scope < ApplicationPolicy::Scope
  23. def resolve
  24. if admin? || legal_staff? || manager?
  25. scope.where(organization_id: user.organization_id)
  26. else
  27. scope.none
  28. end
  29. end
  30. end
  31. private
  32. def legal_staff?
  33. user.has_role?("legal")
  34. end
  35. def manager?
  36. user.has_role?("manager") || user.has_role?("general_manager") || user.has_role?("ceo")
  37. end
  38. end
  39. end

app/policies/settings_policy.rb

0.0% lines covered

8 relevant lines. 0 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. class SettingsPolicy < ApplicationPolicy
  3. def show?
  4. admin? || has_permission?("settings.read")
  5. end
  6. def update?
  7. admin? || has_permission?("settings.manage")
  8. end
  9. end

app/policies/templates/generated_document_policy.rb

0.0% lines covered

59 relevant lines. 0 lines covered and 59 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class GeneratedDocumentPolicy < ApplicationPolicy
  4. def index?
  5. true # All authenticated users can list their documents
  6. end
  7. def show?
  8. owner? || can_sign? || employee_document? || hr_staff? || admin?
  9. end
  10. def preview?
  11. show?
  12. end
  13. def download?
  14. show?
  15. end
  16. def destroy?
  17. admin? # Only admins can delete generated documents
  18. end
  19. def sign?
  20. # User can sign if they have a pending signature on this document
  21. record.can_be_signed_by?(user)
  22. end
  23. class Scope < ApplicationPolicy::Scope
  24. def resolve
  25. if user.admin? || hr_staff?
  26. # HR and Admin can see all documents in the organization
  27. scope.where(organization_id: user.organization_id)
  28. else
  29. # Regular users see documents they:
  30. # 1. Requested
  31. # 2. Are the employee on
  32. # 3. Have a signature on (pending or signed)
  33. employee = ::Hr::Employee.for_user(user)
  34. employee_id = employee&.id
  35. conditions = [{ requested_by_id: user.id }]
  36. conditions << { employee_id: employee_id } if employee_id
  37. conditions << { "signatures.user_id" => user.id.to_s }
  38. scope.where(organization_id: user.organization_id).any_of(*conditions)
  39. end
  40. end
  41. private
  42. def hr_staff?
  43. employee = ::Hr::Employee.for_user(user)
  44. employee&.hr_staff? || employee&.hr_manager?
  45. end
  46. end
  47. private
  48. def owner?
  49. record.requested_by_id == user.id
  50. end
  51. def can_sign?
  52. record.signatures.any? { |s| s["user_id"] == user.id.to_s }
  53. end
  54. def employee_document?
  55. employee = ::Hr::Employee.for_user(user)
  56. employee && record.employee_id == employee.id
  57. end
  58. def hr_staff?
  59. employee = ::Hr::Employee.for_user(user)
  60. employee&.hr_staff? || employee&.hr_manager?
  61. end
  62. def admin?
  63. user.admin?
  64. end
  65. end
  66. end

app/services/audit/log_event_service.rb

0.0% lines covered

29 relevant lines. 0 lines covered and 29 lines missed.
    
  1. # frozen_string_literal: true
  2. module Audit
  3. class LogEventService < BaseService
  4. def initialize(event_type:, action:, target: nil, actor: nil, change_data: {}, metadata: {}, tags: [])
  5. super()
  6. @event_type = event_type
  7. @action = action
  8. @target = target
  9. @actor = actor || Current.user
  10. @change_data = change_data
  11. @metadata = metadata
  12. @tags = Array(tags)
  13. end
  14. def call
  15. event = AuditEvent.log(
  16. event_type: @event_type,
  17. action: @action,
  18. target: @target,
  19. actor: @actor,
  20. change_data: @change_data,
  21. metadata: @metadata,
  22. tags: @tags
  23. )
  24. success(event)
  25. rescue StandardError => e
  26. log_error("Failed to create audit event: #{e.message}")
  27. failure("Failed to create audit event: #{e.message}")
  28. end
  29. end
  30. end

app/services/base_service.rb

0.0% lines covered

53 relevant lines. 0 lines covered and 53 lines missed.
    
  1. # frozen_string_literal: true
  2. class BaseService
  3. attr_reader :result, :errors
  4. def self.call(...)
  5. new(...).call
  6. end
  7. def initialize
  8. @result = nil
  9. @errors = []
  10. end
  11. def call
  12. raise NotImplementedError, "#{self.class}#call must be implemented"
  13. end
  14. def success?
  15. errors.empty?
  16. end
  17. def failure?
  18. !success?
  19. end
  20. protected
  21. def success(result = nil)
  22. @result = result
  23. self
  24. end
  25. def failure(error_or_errors)
  26. case error_or_errors
  27. when Array
  28. @errors.concat(error_or_errors)
  29. when ActiveModel::Errors
  30. @errors.concat(error_or_errors.full_messages)
  31. else
  32. @errors << error_or_errors.to_s
  33. end
  34. self
  35. end
  36. def add_error(message)
  37. @errors << message
  38. end
  39. def current_user
  40. Current.user
  41. end
  42. def current_organization
  43. Current.organization
  44. end
  45. def log_info(message, **metadata)
  46. Rails.logger.info("[#{self.class.name}] #{message}", **metadata)
  47. end
  48. def log_error(message, **metadata)
  49. Rails.logger.error("[#{self.class.name}] #{message}", **metadata)
  50. end
  51. def log_warn(message, **metadata)
  52. Rails.logger.warn("[#{self.class.name}] #{message}", **metadata)
  53. end
  54. end

app/services/health_check_service.rb

0.0% lines covered

38 relevant lines. 0 lines covered and 38 lines missed.
    
  1. # frozen_string_literal: true
  2. class HealthCheckService < BaseService
  3. def call
  4. checks = {
  5. mongodb: check_mongodb,
  6. redis: check_redis,
  7. app: app_running?
  8. }
  9. status = checks.values.all? ? "healthy" : "unhealthy"
  10. success(
  11. status: status,
  12. checks: checks,
  13. timestamp: Time.current.iso8601,
  14. version: app_version
  15. )
  16. rescue StandardError => e
  17. log_error("Health check failed: #{e.message}")
  18. failure("Health check failed: #{e.message}")
  19. end
  20. private
  21. def check_mongodb
  22. HealthCheck.mongodb_connected?
  23. rescue StandardError
  24. false
  25. end
  26. def check_redis
  27. return false unless defined?(Redis)
  28. redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
  29. Redis.new(url: redis_url).ping == "PONG"
  30. rescue StandardError
  31. false
  32. end
  33. def app_running?
  34. Rails.application.present?
  35. end
  36. def app_version
  37. ENV.fetch("APP_VERSION", "development")
  38. end
  39. end

app/services/hr/employee_account_service.rb

0.0% lines covered

81 relevant lines. 0 lines covered and 81 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. # Service to create user accounts for employees when contracts are generated
  4. # Uses personal_email as username and identification_number as initial password
  5. #
  6. class EmployeeAccountService
  7. class AccountCreationError < StandardError; end
  8. attr_reader :employee, :errors
  9. def initialize(employee)
  10. @employee = employee
  11. @errors = []
  12. end
  13. # Create a user account for the employee
  14. # Returns the created user or nil if failed
  15. def create_account!
  16. validate_employee_data!
  17. return nil if errors.any?
  18. # Check if user already exists with this email
  19. existing_user = Identity::User.where(email: employee.personal_email).first
  20. if existing_user
  21. @errors << "Ya existe un usuario con el correo #{employee.personal_email}"
  22. return nil
  23. end
  24. user = build_user
  25. if user.save
  26. assign_employee_role(user)
  27. link_employee_to_user(user)
  28. user
  29. else
  30. @errors.concat(user.errors.full_messages)
  31. nil
  32. end
  33. rescue StandardError => e
  34. @errors << "Error al crear cuenta: #{e.message}"
  35. Rails.logger.error "EmployeeAccountService error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
  36. nil
  37. end
  38. # Check if employee can have an account created
  39. def can_create_account?
  40. validate_employee_data!
  41. errors.empty?
  42. end
  43. # Check if employee already has a user account
  44. def has_account?
  45. employee.user_id.present? ||
  46. (employee.personal_email.present? && Identity::User.exists?(email: employee.personal_email))
  47. end
  48. private
  49. def validate_employee_data!
  50. @errors = []
  51. if employee.personal_email.blank?
  52. @errors << "El empleado debe tener un correo personal"
  53. end
  54. if employee.identification_number.blank?
  55. @errors << "El empleado debe tener número de identificación"
  56. end
  57. if employee.identification_number.present? && employee.identification_number.length < 6
  58. @errors << "El número de identificación debe tener al menos 6 caracteres"
  59. end
  60. if employee.first_name.blank? || employee.last_name.blank?
  61. @errors << "El empleado debe tener nombre y apellido"
  62. end
  63. end
  64. def build_user
  65. Identity::User.new(
  66. email: employee.personal_email,
  67. password: employee.identification_number,
  68. password_confirmation: employee.identification_number,
  69. first_name: extract_first_name,
  70. last_name: extract_last_name,
  71. organization: employee.organization,
  72. must_change_password: true,
  73. active: true
  74. )
  75. end
  76. def extract_first_name
  77. employee.display_first_name.presence || employee.personal_email.split('@').first.titleize
  78. end
  79. def extract_last_name
  80. employee.display_last_name.presence || "Usuario"
  81. end
  82. def assign_employee_role(user)
  83. employee_role = Identity::Role.where(name: Identity::Role::EMPLOYEE).first
  84. user.roles << employee_role if employee_role && !user.roles.include?(employee_role)
  85. end
  86. def link_employee_to_user(user)
  87. # Update employee to point to the new user
  88. employee.update!(user_id: user.id)
  89. end
  90. end
  91. end

app/services/hr/hr_service.rb

0.0% lines covered

214 relevant lines. 0 lines covered and 214 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. # Main service for HR operations
  4. # Handles vacation requests, certifications, and employee management
  5. #
  6. # rubocop:disable Metrics/ClassLength
  7. class HrService
  8. attr_reader :actor, :organization
  9. def initialize(actor:, organization: nil)
  10. @actor = actor
  11. @organization = organization || actor.organization
  12. @employee = find_or_create_employee(actor)
  13. end
  14. # ============================================
  15. # Employee Management
  16. # ============================================
  17. def current_employee
  18. @employee
  19. end
  20. def find_employee(user_or_id)
  21. case user_or_id
  22. when Hr::Employee
  23. user_or_id
  24. when Identity::User
  25. Hr::Employee.find_by(user_id: user_or_id.id)
  26. else
  27. Hr::Employee.find(user_or_id)
  28. end
  29. end
  30. def get_subordinates # rubocop:disable Naming/AccessorMethodName
  31. return Hr::Employee.active.where(organization_id: organization.id) if @employee.hr_manager?
  32. @employee.subordinates.active
  33. end
  34. def get_team_calendar(start_date, end_date)
  35. employees = if @employee.hr_manager?
  36. Hr::Employee.where(organization_id: organization.id).pluck(:id)
  37. else
  38. [@employee.id] + @employee.subordinates.pluck(:id)
  39. end
  40. VacationRequest
  41. .where(:employee_id.in => employees)
  42. .approved
  43. .in_date_range(start_date, end_date)
  44. .includes(:employee)
  45. end
  46. # ============================================
  47. # Vacation Requests
  48. # ============================================
  49. def create_vacation_request(start_date:, end_date:, vacation_type: VacationRequest::TYPE_VACATION, reason: nil)
  50. days = calculate_business_days(start_date, end_date)
  51. request = VacationRequest.new(
  52. employee: @employee,
  53. organization: organization,
  54. start_date: start_date,
  55. end_date: end_date,
  56. days_requested: days,
  57. vacation_type: vacation_type,
  58. reason: reason
  59. )
  60. request.save!
  61. request
  62. end
  63. def submit_vacation_request(request)
  64. ensure_own_request!(request)
  65. request.submit!(actor: @employee)
  66. end
  67. def approve_vacation_request(request, reason: nil)
  68. ensure_can_approve!(request)
  69. request.approve!(actor: @employee, reason: reason)
  70. end
  71. def reject_vacation_request(request, reason:)
  72. ensure_can_approve!(request)
  73. request.reject!(actor: @employee, reason: reason)
  74. end
  75. def cancel_vacation_request(request, reason: nil)
  76. raise AuthorizationError, "Cannot cancel this request" unless request.can_cancel?(@employee)
  77. request.cancel!(actor: @employee, reason: reason)
  78. end
  79. def my_vacation_requests
  80. @employee.vacation_requests.order(created_at: :desc)
  81. end
  82. def pending_approvals
  83. if @employee.hr_manager?
  84. VacationRequest
  85. .where(organization_id: organization.id)
  86. .pending
  87. .order(submitted_at: :asc)
  88. else
  89. VacationRequest.for_approval_by(@employee).order(submitted_at: :asc)
  90. end
  91. end
  92. def vacation_balance
  93. {
  94. available: @employee.vacation_balance_days,
  95. used_ytd: @employee.vacation_used_ytd,
  96. accrued_ytd: @employee.vacation_accrued_ytd,
  97. carry_over: @employee.vacation_carry_over,
  98. pending: pending_vacation_days
  99. }
  100. end
  101. # ============================================
  102. # Employment Certification Requests
  103. # ============================================
  104. def create_certification_request(
  105. certification_type: EmploymentCertificationRequest::TYPE_EMPLOYMENT,
  106. purpose: EmploymentCertificationRequest::PURPOSE_OTHER,
  107. **
  108. )
  109. request = EmploymentCertificationRequest.new(
  110. employee: @employee,
  111. organization: organization,
  112. certification_type: certification_type,
  113. purpose: purpose,
  114. **
  115. )
  116. request.save!
  117. request
  118. end
  119. def cancel_certification_request(request, reason: nil)
  120. raise AuthorizationError, "Cannot cancel this request" unless request.can_cancel?(@employee)
  121. request.cancel!(actor: @employee, reason: reason)
  122. end
  123. def my_certification_requests
  124. @employee.certification_requests.order(created_at: :desc)
  125. end
  126. def process_certification_request(request)
  127. ensure_hr_staff!
  128. request.start_processing!(actor: @employee)
  129. end
  130. def complete_certification_request(request, document_uuid: nil, notes: nil)
  131. ensure_hr_staff!
  132. request.complete!(actor: @employee, document_uuid: document_uuid, notes: notes)
  133. end
  134. def reject_certification_request(request, reason:)
  135. ensure_hr_staff!
  136. request.reject!(actor: @employee, reason: reason)
  137. end
  138. def pending_certifications
  139. ensure_hr_staff!
  140. EmploymentCertificationRequest
  141. .where(organization_id: organization.id)
  142. .for_processing
  143. .order(submitted_at: :asc)
  144. end
  145. # ============================================
  146. # Statistics (HR Dashboard)
  147. # ============================================
  148. def statistics
  149. ensure_hr_staff!
  150. {
  151. employees: employee_stats,
  152. vacation_requests: vacation_request_stats,
  153. certification_requests: certification_request_stats
  154. }
  155. end
  156. def employee_stats
  157. ensure_hr_staff!
  158. {
  159. total: Hr::Employee.where(organization_id: organization.id).count,
  160. active: Hr::Employee.active.where(organization_id: organization.id).count,
  161. on_leave: Hr::Employee.on_leave.where(organization_id: organization.id).count,
  162. by_department: Hr::Employee
  163. .where(organization_id: organization.id)
  164. .active
  165. .group_by(&:department)
  166. .transform_values(&:count)
  167. }
  168. end
  169. def vacation_request_stats
  170. base = VacationRequest.where(organization_id: organization.id)
  171. current_year = Date.current.year
  172. {
  173. pending: base.pending.count,
  174. approved_this_year: base.approved.where(:start_date.gte => Date.new(current_year, 1, 1)).count,
  175. rejected_this_year: base.rejected.where(:created_at.gte => Date.new(current_year, 1, 1)).count,
  176. employees_on_vacation_today: base.current.distinct(:employee_id).count
  177. }
  178. end
  179. def certification_request_stats
  180. base = EmploymentCertificationRequest.where(organization_id: organization.id)
  181. current_year = Date.current.year
  182. {
  183. pending: base.pending.count,
  184. processing: base.processing.count,
  185. completed_this_year: base.completed.where(:completed_at.gte => Date.new(current_year, 1, 1)).count,
  186. average_processing_days: calculate_avg_processing_time(base.completed)
  187. }
  188. end
  189. private
  190. def find_or_create_employee(user)
  191. Hr::Employee.find_or_create_for_user!(
  192. user.respond_to?(:user) ? user.user : user,
  193. vacation_balance_days: 15.0 # Default balance for new employees
  194. )
  195. end
  196. def calculate_business_days(start_date, end_date)
  197. count = 0
  198. (start_date..end_date).each do |date|
  199. count += 1 unless date.saturday? || date.sunday?
  200. end
  201. count.to_f
  202. end
  203. def pending_vacation_days
  204. @employee.vacation_requests
  205. .where(:status.in => [VacationRequest::STATUS_PENDING, VacationRequest::STATUS_APPROVED])
  206. .where(:start_date.gte => Date.current)
  207. .sum(:days_requested)
  208. end
  209. def calculate_avg_processing_time(completed_requests)
  210. return 0 if completed_requests.empty?
  211. total_days = completed_requests.sum do |req|
  212. next 0 unless req.completed_at && req.submitted_at
  213. (req.completed_at.to_date - req.submitted_at.to_date).to_i
  214. end
  215. (total_days.to_f / completed_requests.count).round(1)
  216. end
  217. def ensure_own_request!(request)
  218. return if request.employee_id == @employee.id
  219. raise AuthorizationError, "Can only submit your own requests"
  220. end
  221. def ensure_can_approve!(request)
  222. return if request.can_approve?(@employee)
  223. raise AuthorizationError, "Not authorized to approve this request"
  224. end
  225. def ensure_hr_staff!
  226. return if @employee.hr_staff?
  227. raise AuthorizationError, "Only HR staff can perform this action"
  228. end
  229. class AuthorizationError < StandardError; end
  230. end
  231. # rubocop:enable Metrics/ClassLength
  232. end

app/services/hr/vacation_calculator.rb

0.0% lines covered

83 relevant lines. 0 lines covered and 83 lines missed.
    
  1. # frozen_string_literal: true
  2. module Hr
  3. # Calculates vacation entitlement according to Colombian Labor Law (CST Art. 186)
  4. #
  5. # Rules:
  6. # - 15 business days of paid vacation per year of service
  7. # - Vacation accrues proportionally from day one
  8. # - Can accumulate up to 2 periods (30 days max pending)
  9. # - After 4 years, can compensate in cash up to half of annual vacation
  10. #
  11. class VacationCalculator
  12. DAYS_PER_YEAR = 15.0 # 15 días hábiles por año según ley colombiana
  13. MAX_ACCUMULATION_YEARS = 2 # Máximo 2 períodos acumulables
  14. DAYS_IN_YEAR = 365.0
  15. attr_reader :employee
  16. def initialize(employee)
  17. @employee = employee
  18. end
  19. # Total days earned since hire date
  20. def days_accrued
  21. return 0.0 unless employee.hire_date
  22. years_worked = years_of_service
  23. (years_worked * DAYS_PER_YEAR).round(2)
  24. end
  25. # Days used (from employee record)
  26. def days_used
  27. employee.vacation_used_ytd || 0.0
  28. end
  29. # Days pending (accrued - used)
  30. def days_pending
  31. [days_accrued - total_days_used, 0.0].max.round(2)
  32. end
  33. # Days available to request (considering max accumulation)
  34. def days_available
  35. max_allowed = DAYS_PER_YEAR * MAX_ACCUMULATION_YEARS
  36. [days_pending, max_allowed].min.round(2)
  37. end
  38. # Years of service (decimal)
  39. def years_of_service
  40. return 0.0 unless employee.hire_date
  41. days_worked = (Date.current - employee.hire_date.to_date).to_f
  42. (days_worked / DAYS_IN_YEAR).round(4)
  43. end
  44. # Complete years of service (integer)
  45. def complete_years_of_service
  46. years_of_service.floor
  47. end
  48. # Days accrued in current year (proportional)
  49. def days_accrued_current_year
  50. return 0.0 unless employee.hire_date
  51. # Calculate from anniversary date or hire date in current year
  52. anniversary_this_year = calculate_anniversary_this_year
  53. days_since_anniversary = (Date.current - anniversary_this_year).to_f
  54. return 0.0 if days_since_anniversary.negative?
  55. ((days_since_anniversary / DAYS_IN_YEAR) * DAYS_PER_YEAR).round(2)
  56. end
  57. # Days that will expire if not taken (over max accumulation)
  58. def days_expiring
  59. excess = days_pending - (DAYS_PER_YEAR * MAX_ACCUMULATION_YEARS)
  60. [excess, 0.0].max.round(2)
  61. end
  62. # Can request cash compensation? (after 4 years, up to half)
  63. def can_compensate_in_cash?
  64. complete_years_of_service >= 4
  65. end
  66. # Max days that can be compensated in cash
  67. def max_cash_compensation_days
  68. return 0.0 unless can_compensate_in_cash?
  69. (DAYS_PER_YEAR / 2).round(2) # Half of annual vacation
  70. end
  71. # Summary hash for API response
  72. def summary
  73. {
  74. hire_date: employee.hire_date&.iso8601,
  75. years_of_service: years_of_service.round(2),
  76. complete_years: complete_years_of_service,
  77. days_per_year: DAYS_PER_YEAR,
  78. days_accrued_total: days_accrued,
  79. days_accrued_current_year: days_accrued_current_year,
  80. days_used_total: total_days_used,
  81. days_used_current_year: days_used,
  82. days_pending: days_pending,
  83. days_available: days_available,
  84. days_expiring: days_expiring,
  85. max_accumulation_days: DAYS_PER_YEAR * MAX_ACCUMULATION_YEARS,
  86. can_compensate_cash: can_compensate_in_cash?,
  87. max_cash_compensation: max_cash_compensation_days
  88. }
  89. end
  90. private
  91. def total_days_used
  92. # Sum all approved vacation requests
  93. employee.vacation_requests
  94. .where(status: "approved")
  95. .sum(:days_requested) || 0.0
  96. end
  97. def calculate_anniversary_this_year
  98. hire = employee.hire_date.to_date
  99. anniversary = Date.new(Date.current.year, hire.month, hire.day)
  100. # If anniversary hasn't happened yet this year, use last year's
  101. anniversary > Date.current ? anniversary.prev_year : anniversary
  102. rescue ArgumentError
  103. # Handle Feb 29 for non-leap years
  104. Date.new(Date.current.year, hire.month, hire.day - 1)
  105. end
  106. end
  107. end

app/services/retention/retention_service.rb

0.0% lines covered

150 relevant lines. 0 lines covered and 150 lines missed.
    
  1. # frozen_string_literal: true
  2. module Retention
  3. # Main service for retention management operations
  4. # Handles policy application, schedule management, and legal holds
  5. #
  6. class RetentionService
  7. attr_reader :user, :organization
  8. def initialize(user:, organization: nil)
  9. @user = user
  10. @organization = organization || user.organization
  11. end
  12. # Apply retention policy to a document
  13. def apply_policy(document, policy: nil)
  14. # Find existing schedule or create new one
  15. schedule = RetentionSchedule.where(document_id: document.id).first
  16. if schedule
  17. return schedule if schedule.under_legal_hold?
  18. update_schedule_policy(schedule, policy || find_policy(document))
  19. else
  20. create_schedule(document, policy || find_policy(document))
  21. end
  22. end
  23. # Find applicable policy for a document
  24. def find_policy(document)
  25. RetentionPolicy.find_policy_for(document, organization: organization)
  26. end
  27. # Get retention schedule for a document
  28. def get_schedule(document)
  29. RetentionSchedule.where(document_id: document.id).first
  30. end
  31. # Place a legal hold on a document
  32. def place_legal_hold(document, name:, hold_type:, custodian_name:, **)
  33. LegalHold.place_hold!(
  34. document: document,
  35. name: name,
  36. hold_type: hold_type,
  37. placed_by: user,
  38. organization: organization,
  39. custodian_name: custodian_name,
  40. **
  41. )
  42. end
  43. # Release a legal hold
  44. def release_legal_hold(hold, reason:)
  45. hold.release!(actor: user, reason: reason)
  46. end
  47. # Archive a document (soft action)
  48. def archive_document(document, notes: nil)
  49. schedule = get_schedule(document)
  50. raise RetentionError, "No retention schedule found for document" unless schedule
  51. raise RetentionError, "Document is under legal hold" if schedule.under_legal_hold?
  52. schedule.archive!(actor: user, notes: notes)
  53. end
  54. # Mark document as expired
  55. def expire_document(document, notes: nil)
  56. schedule = get_schedule(document)
  57. raise RetentionError, "No retention schedule found for document" unless schedule
  58. raise RetentionError, "Document is under legal hold" if schedule.under_legal_hold?
  59. schedule.expire!(actor: user, notes: notes)
  60. end
  61. # Extend retention period
  62. def extend_retention(document, additional_days:, reason: nil)
  63. schedule = get_schedule(document)
  64. raise RetentionError, "No retention schedule found for document" unless schedule
  65. schedule.extend_retention!(
  66. additional_days: additional_days,
  67. actor: user,
  68. reason: reason
  69. )
  70. end
  71. # Review a document's retention
  72. def review_document(document, notes: nil)
  73. schedule = get_schedule(document)
  74. raise RetentionError, "No retention schedule found for document" unless schedule
  75. schedule.record_review!(actor: user, notes: notes)
  76. end
  77. # Check if document can be modified
  78. def modification_allowed?(document)
  79. schedule = get_schedule(document)
  80. return true unless schedule
  81. schedule.modification_allowed?
  82. end
  83. # Check if document is under legal hold
  84. def under_legal_hold?(document)
  85. schedule = get_schedule(document)
  86. return false unless schedule
  87. schedule.under_legal_hold?
  88. end
  89. # Get documents expiring soon
  90. def documents_expiring_soon(days: 30)
  91. RetentionSchedule
  92. .expiring_soon(days)
  93. .where(organization_id: organization.id)
  94. .includes(:document, :policy)
  95. end
  96. # Get documents past expiration
  97. def documents_past_expiration
  98. RetentionSchedule
  99. .past_expiration
  100. .where(organization_id: organization.id)
  101. .includes(:document, :policy)
  102. end
  103. # Get documents under legal hold
  104. def documents_under_hold
  105. RetentionSchedule
  106. .held
  107. .where(organization_id: organization.id)
  108. .includes(:document, :legal_holds)
  109. end
  110. # Get active legal holds
  111. def active_legal_holds
  112. LegalHold
  113. .active
  114. .where(organization_id: organization.id)
  115. .includes(:schedule)
  116. end
  117. # Get statistics
  118. # rubocop:disable Metrics/AbcSize
  119. def statistics
  120. {
  121. total_scheduled: RetentionSchedule.where(organization_id: organization.id).count,
  122. active: RetentionSchedule.active.where(organization_id: organization.id).count,
  123. warning: RetentionSchedule.warning.where(organization_id: organization.id).count,
  124. pending_action: RetentionSchedule.pending_action.where(organization_id: organization.id).count,
  125. archived: RetentionSchedule.archived.where(organization_id: organization.id).count,
  126. expired: RetentionSchedule.expired.where(organization_id: organization.id).count,
  127. held: RetentionSchedule.held.where(organization_id: organization.id).count,
  128. active_holds: LegalHold.active.where(organization_id: organization.id).count
  129. }
  130. end
  131. # rubocop:enable Metrics/AbcSize
  132. # Bulk apply policies to documents without schedules
  133. # rubocop:disable Rails/PluckInWhere
  134. def apply_policies_to_unscheduled_documents
  135. documents = Content::Document
  136. .where(organization_id: organization.id)
  137. .where(:id.nin => RetentionSchedule.pluck(:document_id))
  138. count = 0
  139. documents.each do |doc|
  140. policy = find_policy(doc)
  141. next unless policy
  142. create_schedule(doc, policy)
  143. count += 1
  144. end
  145. count
  146. end
  147. # rubocop:enable Rails/PluckInWhere
  148. private
  149. def create_schedule(document, policy)
  150. return nil unless policy
  151. expiration_date = policy.calculate_expiration_date(document)
  152. warning_date = policy.calculate_warning_date(document)
  153. RetentionSchedule.create!(
  154. document: document,
  155. policy: policy,
  156. organization: organization,
  157. retention_start_date: Time.current,
  158. expiration_date: expiration_date,
  159. warning_date: warning_date
  160. )
  161. end
  162. def update_schedule_policy(schedule, policy)
  163. return schedule unless policy
  164. return schedule if schedule.policy_id == policy.id
  165. expiration_date = policy.calculate_expiration_date(schedule.document)
  166. warning_date = policy.calculate_warning_date(schedule.document)
  167. schedule.update!(
  168. policy: policy,
  169. expiration_date: expiration_date,
  170. warning_date: warning_date
  171. )
  172. schedule
  173. end
  174. end
  175. # Custom error class for retention operations
  176. class RetentionError < StandardError; end
  177. end

app/services/search/adapters/elasticsearch_adapter.rb

0.0% lines covered

83 relevant lines. 0 lines covered and 83 lines missed.
    
  1. # frozen_string_literal: true
  2. module Search
  3. module Adapters
  4. # Elasticsearch adapter for full-text search
  5. # This is a placeholder implementation for future use
  6. #
  7. # To enable Elasticsearch:
  8. # 1. Add elasticsearch gem to Gemfile
  9. # 2. Configure connection in config/initializers/elasticsearch.rb
  10. # 3. Set Search::SearchService.default_adapter_class = Search::Adapters::ElasticsearchAdapter
  11. #
  12. # Benefits over MongoDB:
  13. # - True full-text search with linguistic analysis
  14. # - Better relevance scoring (BM25)
  15. # - Highlighting of matched terms
  16. # - Faceted search/aggregations
  17. # - Fuzzy matching and synonyms
  18. # - Much better performance for large datasets
  19. #
  20. class ElasticsearchAdapter < BaseAdapter
  21. INDEX_NAME = "valkyria_documents"
  22. # Index settings for future implementation
  23. INDEX_SETTINGS = {
  24. settings: {
  25. number_of_shards: 1,
  26. number_of_replicas: 0,
  27. analysis: {
  28. analyzer: {
  29. document_analyzer: {
  30. type: "custom",
  31. tokenizer: "standard",
  32. filter: ["lowercase", "asciifolding", "porter_stem"]
  33. }
  34. }
  35. }
  36. },
  37. mappings: {
  38. properties: {
  39. title: {
  40. type: "text",
  41. analyzer: "document_analyzer",
  42. fields: { keyword: { type: "keyword" } }
  43. },
  44. description: {
  45. type: "text",
  46. analyzer: "document_analyzer"
  47. },
  48. content: {
  49. type: "text",
  50. analyzer: "document_analyzer"
  51. },
  52. tags: { type: "keyword" },
  53. status: { type: "keyword" },
  54. document_type: { type: "keyword" },
  55. folder_id: { type: "keyword" },
  56. organization_id: { type: "keyword" },
  57. created_by_id: { type: "keyword" },
  58. metadata: { type: "object", enabled: true },
  59. created_at: { type: "date" },
  60. updated_at: { type: "date" }
  61. }
  62. }
  63. }.freeze
  64. def initialize(options = {})
  65. super
  66. @client = options[:client] || build_client
  67. end
  68. def search(query)
  69. raise NotImplementedError, "Elasticsearch adapter not yet implemented. Use MongoAdapter."
  70. end
  71. def index_document(document)
  72. raise NotImplementedError, "Elasticsearch adapter not yet implemented"
  73. end
  74. def remove_document(document)
  75. raise NotImplementedError, "Elasticsearch adapter not yet implemented"
  76. end
  77. def reindex_all(scope = nil)
  78. raise NotImplementedError, "Elasticsearch adapter not yet implemented"
  79. end
  80. def healthy?
  81. return false unless @client
  82. @client.ping
  83. rescue StandardError
  84. false
  85. end
  86. def supports_full_text?
  87. true
  88. end
  89. def supports_highlighting?
  90. true
  91. end
  92. def supports_facets?
  93. true
  94. end
  95. private
  96. def build_client
  97. # Placeholder - would use Elasticsearch::Client in real implementation
  98. # Elasticsearch::Client.new(
  99. # url: ENV.fetch('ELASTICSEARCH_URL', 'http://localhost:9200'),
  100. # log: Rails.env.development?
  101. # )
  102. nil
  103. end
  104. end
  105. end
  106. end

app/services/search/adapters/mongo_adapter.rb

0.0% lines covered

159 relevant lines. 0 lines covered and 159 lines missed.
    
  1. # frozen_string_literal: true
  2. module Search
  3. module Adapters
  4. # MongoDB-based search adapter
  5. # Uses MongoDB queries for field-based search with regex support
  6. #
  7. # This adapter is suitable for:
  8. # - Small to medium datasets (< 100k documents)
  9. # - Field-based filtering (status, tags, metadata)
  10. # - Simple text matching on titles and descriptions
  11. #
  12. # For large datasets or complex full-text search, consider:
  13. # - ElasticsearchAdapter
  14. # - MeilisearchAdapter
  15. #
  16. # rubocop:disable Metrics/ClassLength
  17. class MongoAdapter < BaseAdapter
  18. # Score weights for ranking
  19. SCORE_WEIGHTS = {
  20. title_exact: 100, # Exact title match
  21. title_starts: 80, # Title starts with query
  22. title_contains: 50, # Title contains query
  23. description: 30, # Description match
  24. tags: 40, # Tag match
  25. metadata: 20, # Metadata match
  26. recency_bonus: 10 # Bonus for recent documents
  27. }.freeze
  28. def search(query)
  29. return invalid_query_result(query) unless query.valid?
  30. start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  31. scope = build_scope(query)
  32. total_count = scope.count
  33. # Apply sorting and pagination
  34. results = apply_sorting(scope, query)
  35. .skip(query.offset)
  36. .limit(query.per_page)
  37. .to_a
  38. # Calculate scores for ranking
  39. scored_results = calculate_scores(results, query)
  40. query_time = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
  41. Results.new(
  42. documents: scored_results,
  43. total_count: total_count,
  44. page: query.page,
  45. per_page: query.per_page,
  46. query_time_ms: query_time,
  47. metadata: build_metadata(query, scope)
  48. )
  49. end
  50. def healthy?
  51. Content::Document.collection.database.command(ping: 1)
  52. true
  53. rescue StandardError
  54. false
  55. end
  56. def supports_full_text?
  57. # MongoDB supports text indexes, but we're using regex for more control
  58. false
  59. end
  60. private
  61. def invalid_query_result(query)
  62. Results.new(
  63. documents: [],
  64. total_count: 0,
  65. page: query.page,
  66. per_page: query.per_page,
  67. metadata: { errors: query.errors }
  68. )
  69. end
  70. def build_scope(query)
  71. scope = base_scope_for_query(query)
  72. scope = apply_text_search(scope, query) if query.text?
  73. scope = apply_filters(scope, query)
  74. apply_permission_filters(scope, query.user, folder_ids: query.filter(:folder_ids))
  75. end
  76. def base_scope_for_query(query)
  77. if query.include_deleted
  78. Content::Document.unscoped.where(organization_id: query.organization_id)
  79. else
  80. Content::Document.where(organization_id: query.organization_id, deleted_at: nil)
  81. end
  82. end
  83. def apply_text_search(scope, query)
  84. text = Regexp.escape(query.text)
  85. regex = /#{text}/i
  86. # Search in title, description, tags, and document_type using $or
  87. # We need to use and() to combine with existing criteria properly
  88. scope.and(
  89. "$or" => [
  90. { title: regex },
  91. { description: regex },
  92. { tags: regex },
  93. { document_type: regex }
  94. ]
  95. )
  96. end
  97. # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
  98. def apply_filters(scope, query)
  99. filters = query.filters
  100. # Title/name filter (exact or partial)
  101. if filters[:title].present?
  102. scope = scope.where(title: /#{Regexp.escape(filters[:title])}/i)
  103. elsif filters[:name].present?
  104. scope = scope.where(title: /#{Regexp.escape(filters[:name])}/i)
  105. end
  106. # Tags filter (match any)
  107. scope = scope.where(:tags.in => Array(filters[:tags])) if filters[:tags].present?
  108. # Status filter
  109. scope = scope.where(status: filters[:status]) if filters[:status].present?
  110. # Document type filter
  111. scope = scope.where(document_type: filters[:document_type]) if filters[:document_type].present?
  112. # Folder filters
  113. if filters[:folder_ids].present?
  114. scope = scope.where(:folder_id.in => filters[:folder_ids])
  115. elsif filters[:folder_id].present?
  116. scope = scope.where(folder_id: filters[:folder_id])
  117. end
  118. # Creator filter
  119. scope = scope.where(created_by_id: filters[:created_by_id]) if filters[:created_by_id].present?
  120. # Metadata filters (nested hash search)
  121. scope = apply_metadata_filters(scope, filters[:metadata]) if filters[:metadata].present?
  122. # Date range filters
  123. apply_date_filters(scope, filters)
  124. end
  125. # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
  126. def apply_metadata_filters(scope, metadata)
  127. metadata.each do |key, value|
  128. # Sanitize key to prevent injection (only allow alphanumeric and underscore)
  129. sanitized_key = key.to_s.gsub(/[^a-zA-Z0-9_]/, "")
  130. next if sanitized_key.empty?
  131. scope = scope.where("metadata.#{sanitized_key}" => value) # brakeman:disable SQLInjection
  132. end
  133. scope
  134. end
  135. def apply_date_filters(scope, filters)
  136. scope = scope.where(:created_at.gte => filters[:created_after]) if filters[:created_after]
  137. scope = scope.where(:created_at.lte => filters[:created_before]) if filters[:created_before]
  138. scope = scope.where(:updated_at.gte => filters[:updated_after]) if filters[:updated_after]
  139. scope = scope.where(:updated_at.lte => filters[:updated_before]) if filters[:updated_before]
  140. scope
  141. end
  142. def apply_sorting(scope, query)
  143. if query.text? && query.sort == Search::Query::SORT_OPTIONS[:relevance]
  144. # For relevance sorting with text search, we'll sort by score later
  145. scope.order(updated_at: :desc)
  146. else
  147. scope.order(query.sort)
  148. end
  149. end
  150. # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
  151. def calculate_scores(documents, query)
  152. return documents unless query.text?
  153. text = query.text.downcase
  154. now = Time.current
  155. scored = documents.map do |doc|
  156. score = 0
  157. # Title scoring
  158. title_lower = doc.title.to_s.downcase
  159. if title_lower == text
  160. score += SCORE_WEIGHTS[:title_exact]
  161. elsif title_lower.start_with?(text)
  162. score += SCORE_WEIGHTS[:title_starts]
  163. elsif title_lower.include?(text)
  164. score += SCORE_WEIGHTS[:title_contains]
  165. end
  166. # Description scoring
  167. score += SCORE_WEIGHTS[:description] if doc.description.to_s.downcase.include?(text)
  168. # Tags scoring
  169. score += SCORE_WEIGHTS[:tags] if doc.tags.any? { |tag| tag.to_s.downcase.include?(text) }
  170. # Metadata scoring
  171. score += SCORE_WEIGHTS[:metadata] if doc.metadata.to_s.downcase.include?(text)
  172. # Recency bonus (documents updated in last 7 days get bonus)
  173. if doc.updated_at && doc.updated_at > 7.days.ago
  174. days_old = [(now - doc.updated_at) / 1.day, 1].max
  175. recency_score = (SCORE_WEIGHTS[:recency_bonus] / days_old).round
  176. score += recency_score
  177. end
  178. # Attach score to document for display
  179. doc.define_singleton_method(:search_score) { score }
  180. doc
  181. end
  182. # Sort by score descending if relevance sorting
  183. if query.sort == Search::Query::SORT_OPTIONS[:relevance]
  184. scored.sort_by { |doc| -doc.search_score }
  185. else
  186. scored
  187. end
  188. end
  189. # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
  190. def build_metadata(query, _scope)
  191. {
  192. adapter: adapter_name,
  193. query_text: query.text,
  194. filters_applied: query.filters.keys,
  195. sort: query.sort
  196. }
  197. end
  198. end
  199. # rubocop:enable Metrics/ClassLength
  200. end
  201. end

app/services/search/base_adapter.rb

0.0% lines covered

47 relevant lines. 0 lines covered and 47 lines missed.
    
  1. # frozen_string_literal: true
  2. module Search
  3. # Abstract base class for search adapters
  4. # Defines the interface that all search backends must implement
  5. #
  6. # This adapter pattern allows plugging in different search backends:
  7. # - MongoAdapter: Uses MongoDB queries (current implementation)
  8. # - ElasticsearchAdapter: For full-text search (future)
  9. # - MeilisearchAdapter: Alternative full-text (future)
  10. # - TypesenseAdapter: Another alternative (future)
  11. #
  12. class BaseAdapter
  13. attr_reader :options
  14. def initialize(options = {})
  15. @options = options
  16. end
  17. # Search for documents matching the given query
  18. #
  19. # @param query [Search::Query] The search query object
  20. # @return [Search::Results] The search results
  21. def search(query)
  22. raise NotImplementedError, "#{self.class}#search must be implemented"
  23. end
  24. # Index a document for searching
  25. # Used by full-text backends to update their index
  26. #
  27. # @param document [Content::Document] The document to index
  28. # @return [Boolean] Success status
  29. # rubocop:disable Naming/PredicateMethod
  30. def index_document(_document)
  31. # Default no-op for backends that don't need indexing (like MongoDB)
  32. true
  33. end
  34. # Remove a document from the search index
  35. #
  36. # @param document [Content::Document] The document to remove
  37. # @return [Boolean] Success status
  38. def remove_document(_document)
  39. # Default no-op for backends that don't need index management
  40. true
  41. end
  42. # rubocop:enable Naming/PredicateMethod
  43. # Reindex all documents
  44. # Used during initial setup or after schema changes
  45. #
  46. # @param scope [Mongoid::Criteria] Optional scope to limit reindexing
  47. # @return [Integer] Number of documents reindexed
  48. def reindex_all(_scope = nil)
  49. # Default no-op
  50. 0
  51. end
  52. # Check if the search backend is available
  53. #
  54. # @return [Boolean] Health status
  55. def healthy?
  56. raise NotImplementedError, "#{self.class}#healthy? must be implemented"
  57. end
  58. # Get adapter name for identification
  59. #
  60. # @return [String] Adapter name
  61. def adapter_name
  62. self.class.name.demodulize.underscore.sub(/_adapter$/, "")
  63. end
  64. # Check if this adapter supports full-text search
  65. #
  66. # @return [Boolean]
  67. def supports_full_text?
  68. false
  69. end
  70. # Check if this adapter supports highlighting
  71. #
  72. # @return [Boolean]
  73. def supports_highlighting?
  74. false
  75. end
  76. # Check if this adapter supports faceted search
  77. #
  78. # @return [Boolean]
  79. def supports_facets?
  80. false
  81. end
  82. protected
  83. # Build base scope with organization filter
  84. def base_scope(organization_id)
  85. Content::Document.active.by_organization(organization_id)
  86. end
  87. # Apply permission filters to scope
  88. def apply_permission_filters(scope, user, options = {})
  89. return scope if user.admin?
  90. # For non-admin users, filter based on folder access
  91. # This is a simplified implementation - can be extended with ACLs
  92. if options[:folder_ids].present?
  93. scope.where(:folder_id.in => options[:folder_ids])
  94. else
  95. scope
  96. end
  97. end
  98. end
  99. end

app/services/search/query.rb

0.0% lines covered

136 relevant lines. 0 lines covered and 136 lines missed.
    
  1. # frozen_string_literal: true
  2. module Search
  3. # Represents a search query with all parameters
  4. # Provides a clean interface for building complex search queries
  5. #
  6. class Query
  7. attr_accessor :text, :filters, :sort, :page, :per_page, :user, :organization_id,
  8. :include_deleted, :highlight, :facets
  9. # Filter keys that are supported
  10. SUPPORTED_FILTERS = [
  11. :title,
  12. :name,
  13. :tags,
  14. :status,
  15. :document_type,
  16. :folder_id,
  17. :folder_ids,
  18. :created_by_id,
  19. :metadata,
  20. :created_after,
  21. :created_before,
  22. :updated_after,
  23. :updated_before
  24. ].freeze
  25. # Sort options
  26. SORT_OPTIONS = {
  27. relevance: { _score: :desc },
  28. newest: { created_at: :desc },
  29. oldest: { created_at: :asc },
  30. title_asc: { title: :asc },
  31. title_desc: { title: :desc },
  32. updated: { updated_at: :desc }
  33. }.freeze
  34. # rubocop:disable Metrics/PerceivedComplexity
  35. def initialize(params = {})
  36. @text = params[:text] || params[:q] || ""
  37. @filters = normalize_filters(params[:filters] || {})
  38. @sort = normalize_sort(params[:sort])
  39. @page = (params[:page] || 1).to_i
  40. @per_page = [(params[:per_page] || 20).to_i, 100].min
  41. @user = params[:user]
  42. @organization_id = params[:organization_id]
  43. @include_deleted = params[:include_deleted] || false
  44. @highlight = params[:highlight] || false
  45. @facets = params[:facets] || []
  46. end
  47. # rubocop:enable Metrics/PerceivedComplexity
  48. def text?
  49. text.present? && text.length >= 2
  50. end
  51. def has_filters? # rubocop:disable Naming/PredicatePrefix
  52. filters.any?
  53. end
  54. def filter(key)
  55. filters[key.to_sym]
  56. end
  57. def add_filter(key, value)
  58. @filters[key.to_sym] = value if SUPPORTED_FILTERS.include?(key.to_sym)
  59. self
  60. end
  61. def remove_filter(key)
  62. @filters.delete(key.to_sym)
  63. self
  64. end
  65. def offset
  66. (page - 1) * per_page
  67. end
  68. def sort_field
  69. sort.keys.first
  70. end
  71. def sort_direction
  72. sort.values.first
  73. end
  74. def valid?
  75. errors.empty?
  76. end
  77. def errors
  78. errs = []
  79. errs << "Organization ID is required" if organization_id.blank?
  80. errs << "User is required" if user.blank?
  81. errs << "Search text too short (minimum 2 characters)" if text.present? && text.length < 2
  82. errs << "Page must be positive" if page < 1
  83. errs << "Per page must be positive" if per_page < 1
  84. errs
  85. end
  86. def to_h
  87. {
  88. text: text,
  89. filters: filters,
  90. sort: sort,
  91. page: page,
  92. per_page: per_page,
  93. organization_id: organization_id&.to_s,
  94. include_deleted: include_deleted
  95. }
  96. end
  97. private
  98. # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
  99. def normalize_filters(raw_filters)
  100. normalized = {}
  101. raw_filters.each do |key, value|
  102. sym_key = key.to_sym
  103. next unless SUPPORTED_FILTERS.include?(sym_key)
  104. next if value.blank?
  105. normalized[sym_key] = case sym_key
  106. when :tags
  107. Array(value)
  108. when :folder_ids
  109. Array(value).map { |id| normalize_id(id) }
  110. when :folder_id, :created_by_id
  111. normalize_id(value)
  112. when :metadata
  113. value.is_a?(Hash) ? value : {}
  114. when :created_after, :created_before, :updated_after, :updated_before
  115. parse_time(value)
  116. else
  117. value
  118. end
  119. end
  120. normalized
  121. end
  122. # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
  123. # rubocop:disable Metrics/PerceivedComplexity
  124. def normalize_sort(sort_param)
  125. return SORT_OPTIONS[:relevance] if sort_param.blank?
  126. if sort_param.is_a?(Hash)
  127. sort_param.transform_keys(&:to_sym).transform_values(&:to_sym)
  128. elsif sort_param.is_a?(Symbol) || sort_param.is_a?(String)
  129. SORT_OPTIONS[sort_param.to_sym] || SORT_OPTIONS[:relevance]
  130. else
  131. SORT_OPTIONS[:relevance]
  132. end
  133. end
  134. # rubocop:enable Metrics/PerceivedComplexity
  135. def normalize_id(value)
  136. return value if value.is_a?(BSON::ObjectId)
  137. BSON::ObjectId.from_string(value.to_s)
  138. rescue BSON::Error::InvalidObjectId
  139. nil
  140. end
  141. def parse_time(value)
  142. return value if value.is_a?(Time) || value.is_a?(DateTime)
  143. Time.zone.parse(value.to_s)
  144. rescue ArgumentError
  145. nil
  146. end
  147. end
  148. end

app/services/search/results.rb

0.0% lines covered

94 relevant lines. 0 lines covered and 94 lines missed.
    
  1. # frozen_string_literal: true
  2. module Search
  3. # Represents search results with pagination and metadata
  4. #
  5. class Results
  6. include Enumerable
  7. attr_reader :documents, :total_count, :page, :per_page, :query_time_ms,
  8. :facets, :highlights, :metadata
  9. def initialize(documents:, total_count:, page:, per_page:, query_time_ms: 0, **options)
  10. @documents = documents
  11. @total_count = total_count
  12. @page = page
  13. @per_page = per_page
  14. @query_time_ms = query_time_ms
  15. @facets = options[:facets] || {}
  16. @highlights = options[:highlights] || {}
  17. @metadata = options[:metadata] || {}
  18. end
  19. def each(&)
  20. documents.each(&)
  21. end
  22. delegate :empty?, to: :documents
  23. delegate :size, to: :documents
  24. alias count size
  25. def total_pages
  26. return 0 if total_count.zero?
  27. (total_count.to_f / per_page).ceil
  28. end
  29. def current_page
  30. page
  31. end
  32. def next_page
  33. page < total_pages ? page + 1 : nil
  34. end
  35. def prev_page
  36. page > 1 ? page - 1 : nil
  37. end
  38. def first_page?
  39. page == 1
  40. end
  41. def last_page?
  42. page >= total_pages
  43. end
  44. def offset
  45. (page - 1) * per_page
  46. end
  47. def has_more? # rubocop:disable Naming/PredicatePrefix
  48. !last_page?
  49. end
  50. # Get highlight for a specific document
  51. def highlight_for(document)
  52. highlights[document.id.to_s] || {}
  53. end
  54. # Pagination info for API responses
  55. def pagination
  56. {
  57. current_page: page,
  58. per_page: per_page,
  59. total_pages: total_pages,
  60. total_count: total_count,
  61. has_next: has_more?,
  62. has_prev: prev_page.present?
  63. }
  64. end
  65. # Full response for API
  66. def to_h
  67. {
  68. documents: documents.map { |d| document_to_h(d) },
  69. pagination: pagination,
  70. facets: facets,
  71. metadata: metadata.merge(query_time_ms: query_time_ms)
  72. }
  73. end
  74. # Create empty results
  75. def self.empty(page: 1, per_page: 20)
  76. new(
  77. documents: [],
  78. total_count: 0,
  79. page: page,
  80. per_page: per_page
  81. )
  82. end
  83. private
  84. def document_to_h(doc)
  85. {
  86. id: doc.id.to_s,
  87. uuid: doc.uuid,
  88. title: doc.title,
  89. description: doc.description,
  90. status: doc.status,
  91. document_type: doc.document_type,
  92. tags: doc.tags,
  93. folder_id: doc.folder_id&.to_s,
  94. created_at: doc.created_at&.iso8601,
  95. updated_at: doc.updated_at&.iso8601,
  96. version_count: doc.version_count,
  97. score: doc.try(:search_score)
  98. }.compact
  99. end
  100. end
  101. end

app/services/search/search_service.rb

0.0% lines covered

95 relevant lines. 0 lines covered and 95 lines missed.
    
  1. # frozen_string_literal: true
  2. module Search
  3. # Main search service that coordinates search operations
  4. # Uses the configured adapter to perform searches
  5. #
  6. # Usage:
  7. # service = Search::SearchService.new(user: current_user, organization: current_org)
  8. # results = service.search("quarterly report", filters: { status: "published" })
  9. #
  10. # Or with the class method:
  11. # results = Search::SearchService.search(
  12. # text: "quarterly report",
  13. # user: current_user,
  14. # organization_id: org.id,
  15. # filters: { tags: ["finance"] }
  16. # )
  17. #
  18. class SearchService
  19. attr_reader :adapter, :user, :organization_id
  20. # Configure the default adapter
  21. class << self
  22. attr_accessor :default_adapter_class
  23. def search(params)
  24. new(
  25. user: params[:user],
  26. organization_id: params[:organization_id] || params[:user]&.organization_id
  27. ).search(params[:text] || params[:q], params.except(:user, :organization_id, :text, :q))
  28. end
  29. def default_adapter
  30. @default_adapter ||= Adapters::MongoAdapter
  31. end
  32. end
  33. def initialize(user:, organization_id: nil, adapter: nil)
  34. @user = user
  35. @organization_id = organization_id || user&.organization_id
  36. @adapter = adapter || self.class.default_adapter.new
  37. end
  38. # Perform a search
  39. #
  40. # @param text [String] The search text (optional)
  41. # @param options [Hash] Search options
  42. # @option options [Hash] :filters Field filters
  43. # @option options [Symbol, Hash] :sort Sort order
  44. # @option options [Integer] :page Page number
  45. # @option options [Integer] :per_page Results per page
  46. # @return [Search::Results]
  47. #
  48. def search(text = nil, options = {})
  49. query = build_query(text, options)
  50. # Log search for audit trail
  51. log_search(query)
  52. adapter.search(query)
  53. end
  54. # Search by title/name
  55. def search_by_title(title, options = {})
  56. search(nil, options.merge(filters: (options[:filters] || {}).merge(title: title)))
  57. end
  58. # Search by tags
  59. def search_by_tags(tags, options = {})
  60. search(nil, options.merge(filters: (options[:filters] || {}).merge(tags: Array(tags))))
  61. end
  62. # Search by metadata
  63. def search_by_metadata(metadata, options = {})
  64. search(nil, options.merge(filters: (options[:filters] || {}).merge(metadata: metadata)))
  65. end
  66. # Search within a specific folder
  67. def search_in_folder(folder_or_id, text = nil, options = {})
  68. folder_id = folder_or_id.is_a?(Content::Folder) ? folder_or_id.id : folder_or_id
  69. search(text, options.merge(filters: (options[:filters] || {}).merge(folder_id: folder_id)))
  70. end
  71. # Search within multiple folders
  72. def search_in_folders(folder_ids, text = nil, options = {})
  73. ids = folder_ids.map { |f| f.is_a?(Content::Folder) ? f.id : f }
  74. search(text, options.merge(filters: (options[:filters] || {}).merge(folder_ids: ids)))
  75. end
  76. # Get documents by status
  77. def by_status(status, options = {})
  78. search(nil, options.merge(filters: (options[:filters] || {}).merge(status: status)))
  79. end
  80. # Get recent documents
  81. def recent(limit = 10)
  82. search(nil, sort: :newest, per_page: limit)
  83. end
  84. # Get documents created by a specific user
  85. def by_creator(user_or_id, options = {})
  86. creator_id = user_or_id.is_a?(Identity::User) ? user_or_id.id : user_or_id
  87. search(nil, options.merge(filters: (options[:filters] || {}).merge(created_by_id: creator_id)))
  88. end
  89. # Advanced search with multiple criteria
  90. #
  91. # @param criteria [Hash] Search criteria
  92. # @option criteria [String] :text Free text search
  93. # @option criteria [String] :title Title filter
  94. # @option criteria [Array<String>] :tags Tag filters
  95. # @option criteria [String] :status Status filter
  96. # @option criteria [String] :document_type Document type filter
  97. # @option criteria [Hash] :metadata Metadata filters
  98. # @option criteria [Time] :created_after Created after date
  99. # @option criteria [Time] :created_before Created before date
  100. #
  101. def advanced_search(criteria = {})
  102. text = criteria.delete(:text) || criteria.delete(:q)
  103. filters = criteria.slice(*Query::SUPPORTED_FILTERS)
  104. options = criteria.except(*Query::SUPPORTED_FILTERS)
  105. search(text, options.merge(filters: filters))
  106. end
  107. # Check if adapter is healthy
  108. delegate :healthy?, to: :adapter
  109. private
  110. def build_query(text, options)
  111. Query.new(
  112. text: text,
  113. filters: options[:filters] || {},
  114. sort: options[:sort],
  115. page: options[:page],
  116. per_page: options[:per_page],
  117. user: user,
  118. organization_id: organization_id,
  119. include_deleted: options[:include_deleted],
  120. highlight: options[:highlight],
  121. facets: options[:facets]
  122. )
  123. end
  124. def log_search(query)
  125. return unless query.valid?
  126. Audit::AuditEvent.create(
  127. event_type: Audit::AuditEvent::TYPES[:content],
  128. action: "search_performed",
  129. actor_id: user&.id,
  130. actor_type: user&.class&.name,
  131. actor_email: user.try(:email),
  132. organization_id: organization_id,
  133. metadata: {
  134. search_text: query.text.presence,
  135. filters: query.filters,
  136. adapter: adapter.adapter_name
  137. },
  138. tags: ["search"]
  139. )
  140. rescue StandardError => e
  141. Rails.logger.warn("Failed to log search audit: #{e.message}")
  142. end
  143. end
  144. end

app/services/service_result.rb

0.0% lines covered

32 relevant lines. 0 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. class ServiceResult
  3. attr_reader :data, :errors, :metadata
  4. def initialize(success:, data: nil, errors: [], metadata: {})
  5. @success = success
  6. @data = data
  7. @errors = Array(errors)
  8. @metadata = metadata
  9. end
  10. def self.success(data = nil, metadata: {})
  11. new(success: true, data: data, metadata: metadata)
  12. end
  13. def self.failure(errors, metadata: {})
  14. new(success: false, errors: Array(errors), metadata: metadata)
  15. end
  16. def success?
  17. @success
  18. end
  19. def failure?
  20. !success?
  21. end
  22. def error_messages
  23. errors.join(", ")
  24. end
  25. def to_h
  26. {
  27. success: success?,
  28. data: data,
  29. errors: errors,
  30. metadata: metadata
  31. }
  32. end
  33. end

app/services/templates/document_generator_service.rb

0.0% lines covered

171 relevant lines. 0 lines covered and 171 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class DocumentGeneratorService
  4. attr_reader :template, :context, :variable_values
  5. def initialize(template, context)
  6. @template = template
  7. @context = context
  8. @variable_values = {}
  9. end
  10. # Generate a document from template
  11. def generate!
  12. validate_template!
  13. resolve_variables!
  14. generate_document!
  15. end
  16. private
  17. def validate_template!
  18. raise GenerationError, "Template no activo" unless template.active?
  19. raise GenerationError, "Template sin archivo" unless template.file_id
  20. end
  21. def resolve_variables!
  22. resolver = VariableResolverService.new(context)
  23. @variable_values = resolver.resolve_for_template(template)
  24. end
  25. def generate_document!
  26. # Get template content
  27. template_content = template.file_content
  28. raise GenerationError, "No se pudo leer el archivo del template" unless template_content
  29. # Create temp file for processing
  30. input_file = Tempfile.new(["template", ".docx"])
  31. input_file.binmode
  32. input_file.write(template_content)
  33. input_file.rewind
  34. begin
  35. # Replace variables in the document
  36. doc = Docx::Document.open(input_file.path)
  37. replace_variables_in_document!(doc)
  38. # Save modified document
  39. output_docx = Tempfile.new(["output", ".docx"])
  40. doc.save(output_docx.path)
  41. # Convert to PDF
  42. pdf_content = convert_to_pdf(output_docx.path)
  43. # Create GeneratedDocument record
  44. create_generated_document(pdf_content)
  45. ensure
  46. input_file.close
  47. input_file.unlink
  48. output_docx&.close
  49. output_docx&.unlink
  50. end
  51. end
  52. def replace_variables_in_document!(doc)
  53. # Replace in paragraphs
  54. doc.paragraphs.each do |para|
  55. replace_in_paragraph!(para)
  56. end
  57. # Replace in tables
  58. doc.tables.each do |table|
  59. table.rows.each do |row|
  60. row.cells.each do |cell|
  61. cell.paragraphs.each do |para|
  62. replace_in_paragraph!(para)
  63. end
  64. end
  65. end
  66. end
  67. end
  68. def replace_in_paragraph!(para)
  69. variable_values.each do |variable_name, value|
  70. pattern = "{{#{variable_name}}}"
  71. replacement = value.to_s
  72. # Handle paragraph text replacement
  73. para.each_text_run do |run|
  74. run.text = run.text.gsub(pattern, replacement) if run.text.include?(pattern)
  75. end
  76. end
  77. end
  78. def convert_to_pdf(docx_path)
  79. # Try using LibreOffice for conversion (if available)
  80. if libreoffice_available?
  81. convert_with_libreoffice(docx_path)
  82. else
  83. # Fallback to Prawn-based PDF generation
  84. convert_with_prawn(docx_path)
  85. end
  86. end
  87. def libreoffice_available?
  88. # Check common LibreOffice paths on macOS, Linux, and Heroku
  89. paths_to_check = [
  90. "/app/.apt/usr/bin/soffice", # Heroku apt buildpack path
  91. "/Applications/LibreOffice.app/Contents/MacOS/soffice",
  92. "/usr/local/bin/soffice",
  93. "/usr/bin/soffice",
  94. "/usr/bin/libreoffice"
  95. ]
  96. @libreoffice_path = paths_to_check.find { |p| File.exist?(p) }
  97. @libreoffice_path ||= `which soffice 2>/dev/null`.strip.presence
  98. @libreoffice_path ||= `which libreoffice 2>/dev/null`.strip.presence
  99. @libreoffice_path.present?
  100. end
  101. def convert_with_libreoffice(docx_path)
  102. output_dir = Dir.mktmpdir
  103. user_profile = Dir.mktmpdir("lo_profile")
  104. begin
  105. # Set environment for Heroku apt buildpack
  106. lib_path = "/app/.apt/usr/lib/libreoffice/program:/app/.apt/usr/lib/x86_64-linux-gnu"
  107. env_vars = [
  108. "LD_LIBRARY_PATH=#{lib_path}:$LD_LIBRARY_PATH",
  109. "HOME=/tmp"
  110. ].join(" ")
  111. # Use -env:UserInstallation to avoid profile issues
  112. user_install = "-env:UserInstallation=file://#{user_profile}"
  113. cmd = "#{env_vars} \"#{@libreoffice_path}\" --headless #{user_install} --convert-to pdf --outdir \"#{output_dir}\" \"#{docx_path}\" 2>&1"
  114. Rails.logger.info "Running LibreOffice: #{cmd}"
  115. result = `#{cmd}`
  116. Rails.logger.info "LibreOffice conversion result: #{result}"
  117. # Find the generated PDF
  118. pdf_files = Dir.glob(File.join(output_dir, "*.pdf"))
  119. if pdf_files.empty?
  120. Rails.logger.error "LibreOffice conversion failed, falling back to Prawn"
  121. return convert_with_prawn(docx_path)
  122. end
  123. File.binread(pdf_files.first)
  124. ensure
  125. FileUtils.rm_rf(output_dir)
  126. FileUtils.rm_rf(user_profile)
  127. end
  128. end
  129. def convert_with_prawn(docx_path)
  130. # Fallback: Generate a basic PDF with Prawn
  131. doc = Docx::Document.open(docx_path)
  132. Prawn::Document.new do |pdf|
  133. pdf.font_families.update(
  134. "DejaVu" => {
  135. normal: Rails.root.join("app/assets/fonts/DejaVuSans.ttf").to_s,
  136. bold: Rails.root.join("app/assets/fonts/DejaVuSans-Bold.ttf").to_s,
  137. italic: Rails.root.join("app/assets/fonts/DejaVuSans-Oblique.ttf").to_s
  138. }
  139. ) if File.exist?(Rails.root.join("app/assets/fonts/DejaVuSans.ttf"))
  140. pdf.font("DejaVu") if pdf.font_families.key?("DejaVu")
  141. doc.paragraphs.each do |para|
  142. text = para.text.strip
  143. next if text.empty?
  144. # Basic styling
  145. if para.node["pStyle"]&.include?("Heading")
  146. pdf.text text, size: 16, style: :bold
  147. pdf.move_down 10
  148. else
  149. pdf.text text, size: 11
  150. pdf.move_down 5
  151. end
  152. end
  153. # Handle tables
  154. doc.tables.each do |table|
  155. table_data = table.rows.map do |row|
  156. row.cells.map { |cell| cell.paragraphs.map(&:text).join("\n") }
  157. end
  158. if table_data.any?
  159. pdf.table(table_data, width: pdf.bounds.width) do
  160. cells.padding = 5
  161. cells.border_width = 0.5
  162. end
  163. pdf.move_down 10
  164. end
  165. end
  166. end.render
  167. end
  168. def create_generated_document(pdf_content)
  169. # Store PDF in GridFS
  170. file_name = "#{template.name.parameterize}-#{Time.current.strftime('%Y%m%d%H%M%S')}.pdf"
  171. pdf_file = Mongoid::GridFs.put(
  172. StringIO.new(pdf_content),
  173. filename: file_name,
  174. content_type: "application/pdf"
  175. )
  176. # Create the GeneratedDocument record
  177. generated_doc = GeneratedDocument.create!(
  178. name: file_name,
  179. template: template,
  180. organization: context[:organization],
  181. requested_by: context[:user],
  182. draft_file_id: pdf_file.id,
  183. file_name: file_name,
  184. variable_values: variable_values,
  185. source: context[:request],
  186. employee: context[:employee]
  187. )
  188. # Initialize signatures
  189. generated_doc.initialize_signatures!
  190. generated_doc
  191. end
  192. class GenerationError < StandardError; end
  193. end
  194. end

app/services/templates/pdf_signature_service.rb

0.0% lines covered

162 relevant lines. 0 lines covered and 162 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class PdfSignatureService
  4. def initialize(generated_document)
  5. @generated_document = generated_document
  6. end
  7. # Apply all collected signatures to the PDF
  8. # Always reads from draft_file_id to avoid double-applying signatures
  9. def apply_all_signatures!
  10. return unless @generated_document.all_required_signed?
  11. # Always read from draft (original PDF without signatures)
  12. # This prevents double-application when apply_signature_to_pdf! was called earlier
  13. pdf_content = read_draft_pdf
  14. raise SignatureError, "No se pudo leer el PDF draft" unless pdf_content
  15. # Create working files
  16. input_pdf = Tempfile.new(["input", ".pdf"])
  17. input_pdf.binmode
  18. input_pdf.write(pdf_content)
  19. input_pdf.rewind
  20. begin
  21. # Load the PDF
  22. pdf = CombinePDF.load(input_pdf.path)
  23. pages = pdf.pages
  24. total_pages = pages.count
  25. # Get actual PDF page height
  26. actual_page_height = pages.first&.mediabox&.dig(3) || 792.0
  27. # Get template's stored preview page height (used when coordinates were captured)
  28. template = @generated_document.template
  29. preview_page_height = template&.preview_page_height || 792.0
  30. # Calculate scale factor if template preview height differs from actual PDF
  31. # This handles cases where the preview was at a different scale than the actual PDF
  32. scale_factor = actual_page_height / preview_page_height
  33. Rails.logger.info "PDF rendering: actual_page_height=#{actual_page_height}, preview_page_height=#{preview_page_height}, scale_factor=#{scale_factor}"
  34. # Apply each signature to the correct page
  35. @generated_document.signed_signatories.each do |sig_entry|
  36. signatory = find_signatory(sig_entry["signatory_id"])
  37. next unless signatory
  38. # Get stored coordinates (captured at preview_page_height scale)
  39. stored_y = signatory.y_position || 0
  40. # Scale coordinates to actual PDF dimensions
  41. absolute_y = stored_y * scale_factor
  42. # Calculate which page this Y coordinate falls on (0-indexed)
  43. calculated_page_index = (absolute_y / actual_page_height).floor
  44. # Clamp to valid range
  45. page_index = [[calculated_page_index, 0].max, total_pages - 1].min
  46. target_page = pages[page_index]
  47. Rails.logger.info "Signature #{signatory.label}: stored_y=#{stored_y}, absolute_y=#{absolute_y}, page_height=#{actual_page_height}, calculated_page=#{calculated_page_index + 1}, actual_page=#{page_index + 1}"
  48. apply_signature_to_page(target_page, sig_entry, page_index, actual_page_height)
  49. end
  50. # Save the final PDF
  51. output_pdf = Tempfile.new(["signed", ".pdf"])
  52. pdf.save(output_pdf.path)
  53. # Store in GridFS
  54. store_final_pdf(File.read(output_pdf.path))
  55. ensure
  56. input_pdf.close
  57. input_pdf.unlink
  58. output_pdf&.close
  59. output_pdf&.unlink
  60. end
  61. end
  62. # Apply a single signature when it's added
  63. def apply_single_signature!(signature, signatory)
  64. # For now, signatures are collected and applied all at once
  65. # This method could be used for real-time preview
  66. true
  67. end
  68. private
  69. def apply_signature_to_page(page, sig_entry, page_index, page_height)
  70. # Get signature data
  71. signature = find_signature(sig_entry["signature_id"])
  72. return unless signature
  73. signatory = find_signatory(sig_entry["signatory_id"])
  74. return unless signatory
  75. # Get signature as PNG image
  76. image_data = get_signature_image(signature)
  77. return unless image_data
  78. # Log signature placement for debugging
  79. Rails.logger.info "Applying signature for #{signatory.label} to page #{page_index + 1} at position (#{signatory.x_position}, #{signatory.y_position})"
  80. # Create signature overlay using Prawn
  81. overlay_pdf = create_signature_overlay(
  82. image_data: image_data,
  83. signatory: signatory,
  84. sig_entry: sig_entry,
  85. page_width: page.mediabox[2],
  86. page_height: page_height,
  87. target_page_index: page_index
  88. )
  89. # Merge overlay onto page
  90. overlay = CombinePDF.parse(overlay_pdf)
  91. page << overlay.pages.first
  92. end
  93. def find_signature(signature_uuid)
  94. return nil if signature_uuid.blank?
  95. Identity::UserSignature.where(uuid: signature_uuid).first
  96. end
  97. def find_signatory(signatory_uuid)
  98. return nil if signatory_uuid.blank?
  99. return nil unless @generated_document.template
  100. @generated_document.template.signatories.where(uuid: signatory_uuid).first
  101. end
  102. def get_signature_image(signature)
  103. renderer = SignatureRendererService.new(signature)
  104. tempfile = renderer.to_tempfile
  105. begin
  106. File.read(tempfile.path)
  107. ensure
  108. tempfile.close
  109. tempfile.unlink
  110. end
  111. end
  112. def create_signature_overlay(image_data:, signatory:, sig_entry:, page_width:, page_height:, target_page_index:)
  113. # Get position from signatory config
  114. box = signatory.signature_box
  115. x = box[:x]
  116. absolute_y = box[:y] # y from top of ENTIRE document (absolute coordinate)
  117. width = box[:width]
  118. height = box[:height]
  119. show_label = box[:show_label].nil? ? true : box[:show_label]
  120. show_signer_name = box[:show_signer_name] || false
  121. date_position = box[:date_position] || "right"
  122. # Convert absolute Y to per-page Y coordinate
  123. # absolute_y is the distance from top of the entire document
  124. # We need to convert it to distance from top of the specific page
  125. y = absolute_y - (target_page_index * page_height)
  126. Rails.logger.info "Signature placement: absolute_y=#{absolute_y}, page_index=#{target_page_index}, page_height=#{page_height}, y_on_page=#{y}"
  127. # Create temp image file
  128. img_file = Tempfile.new(["sig", ".png"])
  129. img_file.binmode
  130. img_file.write(image_data)
  131. img_file.rewind
  132. begin
  133. pdf = Prawn::Document.new(
  134. page_size: [page_width, page_height],
  135. margin: 0,
  136. skip_page_creation: false
  137. )
  138. # Calculate position from bottom (Prawn uses bottom-left origin)
  139. # y_from_top = 700 means the signature box TOP is 700pt from page top
  140. # So bottom of signature box is at: page_height - y - height
  141. y_from_bottom = page_height - y - height
  142. # Calculate text space needed
  143. text_lines = 0
  144. text_lines += 1 if show_label
  145. text_lines += 1 if show_signer_name
  146. text_lines += 1 if date_position != "none"
  147. text_space = text_lines * 12
  148. # Draw signature image at absolute position
  149. pdf.image img_file.path, at: [x, y_from_bottom + height], fit: [width, height - text_space]
  150. # Add optional elements below signature
  151. current_y = y_from_bottom + text_space - 5
  152. if show_label
  153. pdf.draw_text signatory.label, at: [x + (width / 2) - 40, current_y], size: 8
  154. current_y -= 12
  155. end
  156. if show_signer_name && sig_entry["signed_by_name"].present?
  157. pdf.fill_color "333333"
  158. pdf.draw_text "Firmado por: #{sig_entry['signed_by_name']}", at: [x + (width / 2) - 50, current_y], size: 7
  159. pdf.fill_color "000000"
  160. current_y -= 12
  161. end
  162. if date_position != "none"
  163. pdf.fill_color "666666"
  164. pdf.draw_text "Firmado: #{format_date(sig_entry['signed_at'])}", at: [x + (width / 2) - 50, current_y], size: 7
  165. pdf.fill_color "000000"
  166. end
  167. pdf.render
  168. ensure
  169. img_file.close
  170. img_file.unlink
  171. end
  172. end
  173. # Read the original draft PDF (without any signatures applied)
  174. # Uses original_draft_file_id if available, falls back to draft_file_id
  175. def read_draft_pdf
  176. # Prefer original_draft_file_id (clean PDF without any signatures)
  177. file_id = @generated_document.original_draft_file_id || @generated_document.draft_file_id
  178. return nil unless file_id
  179. file = Mongoid::GridFs.get(file_id)
  180. file.data
  181. rescue StandardError => e
  182. Rails.logger.error "Error reading draft PDF: #{e.message}"
  183. nil
  184. end
  185. def store_final_pdf(pdf_content)
  186. file_name = @generated_document.file_name.sub(".pdf", "-firmado.pdf")
  187. pdf_file = Mongoid::GridFs.put(
  188. StringIO.new(pdf_content),
  189. filename: file_name,
  190. content_type: "application/pdf"
  191. )
  192. @generated_document.update!(final_file_id: pdf_file.id)
  193. end
  194. def format_date(date_string)
  195. return "" unless date_string
  196. Time.parse(date_string).strftime("%d/%m/%Y %H:%M")
  197. rescue StandardError
  198. date_string.to_s
  199. end
  200. class SignatureError < StandardError; end
  201. end
  202. end

app/services/templates/robust_document_generator_service.rb

0.0% lines covered

736 relevant lines. 0 lines covered and 736 lines missed.
    
  1. # frozen_string_literal: true
  2. require "zip"
  3. require "nokogiri"
  4. module Templates
  5. # Robust document generator that handles fragmented Word XML runs
  6. # and preserves formatting when replacing variables
  7. class RobustDocumentGeneratorService
  8. attr_reader :template, :context, :variable_values, :replacement_log
  9. WORD_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  10. VARIABLE_PATTERN = /\{\{([^}]+)\}\}/
  11. def initialize(template, context)
  12. @template = template
  13. @context = context
  14. @variable_values = {}
  15. @replacement_log = []
  16. end
  17. # Generate a document from template
  18. def generate!
  19. validate_template!
  20. resolve_variables!
  21. validate_required_variables!
  22. log_variables_to_replace
  23. generate_document!
  24. end
  25. # Validate variables without generating - returns hash with missing info
  26. def validate_variables
  27. validate_template!
  28. resolve_variables!
  29. find_missing_variables
  30. end
  31. private
  32. def validate_template!
  33. raise GenerationError, "Template no activo" unless template.active?
  34. raise GenerationError, "Template sin archivo" unless template.file_id
  35. end
  36. def resolve_variables!
  37. resolver = VariableResolverService.new(context)
  38. @variable_values = resolver.resolve_for_template(template)
  39. end
  40. def validate_required_variables!
  41. missing = find_missing_variables
  42. return if missing[:variables].empty?
  43. error_message = build_missing_variables_error(missing)
  44. raise MissingVariablesError.new(error_message, missing)
  45. end
  46. def find_missing_variables
  47. missing_vars = []
  48. template.variables.each do |var_name|
  49. path = template.variable_mappings[var_name]
  50. value = @variable_values[var_name]
  51. # Variable sin mapeo
  52. if path.nil? || path.empty?
  53. missing_vars << {
  54. variable: var_name,
  55. path: nil,
  56. reason: "sin_mapeo",
  57. source: nil,
  58. field: nil
  59. }
  60. next
  61. end
  62. # Variable con valor vacío o nulo
  63. if value.nil? || value.to_s.strip.empty?
  64. parts = path.split(".")
  65. source = parts.first
  66. field = parts[1..].join(".")
  67. missing_vars << {
  68. variable: var_name,
  69. path: path,
  70. reason: "sin_valor",
  71. source: source,
  72. field: field,
  73. field_label: humanize_field(field)
  74. }
  75. end
  76. end
  77. {
  78. variables: missing_vars,
  79. by_source: group_by_source(missing_vars),
  80. employee_id: context[:employee]&.uuid,
  81. employee_name: context[:employee]&.full_name
  82. }
  83. end
  84. def group_by_source(missing_vars)
  85. missing_vars.group_by { |v| v[:source] }.transform_values do |vars|
  86. vars.map { |v| { variable: v[:variable], field: v[:field], field_label: v[:field_label] } }
  87. end
  88. end
  89. def humanize_field(field)
  90. translations = {
  91. "full_name" => "Nombre Completo",
  92. "identification_number" => "Número de Identificación",
  93. "identification_type" => "Tipo de Documento",
  94. "job_title" => "Cargo",
  95. "department" => "Departamento",
  96. "hire_date" => "Fecha de Ingreso",
  97. "salary" => "Salario",
  98. "food_allowance" => "Auxilio de Alimentación",
  99. "transport_allowance" => "Auxilio de Transporte",
  100. "contract_type" => "Tipo de Contrato",
  101. "contract_start_date" => "Fecha Inicio Contrato",
  102. "contract_end_date" => "Fecha Fin Contrato",
  103. "address" => "Dirección",
  104. "phone" => "Teléfono",
  105. "email" => "Correo Electrónico",
  106. "place_of_birth" => "Lugar de Nacimiento",
  107. "nationality" => "Nacionalidad",
  108. "date_of_birth" => "Fecha de Nacimiento",
  109. "name" => "Nombre",
  110. "tax_id" => "NIT",
  111. "nit" => "NIT",
  112. "city" => "Ciudad"
  113. }
  114. translations[field] || field.humanize
  115. end
  116. def build_missing_variables_error(missing)
  117. vars = missing[:variables]
  118. by_source = vars.group_by { |v| v[:source] }
  119. messages = []
  120. if by_source["employee"]&.any?
  121. fields = by_source["employee"].map { |v| v[:field_label] || v[:field] }.join(", ")
  122. messages << "Datos del empleado faltantes: #{fields}"
  123. end
  124. if by_source["organization"]&.any?
  125. fields = by_source["organization"].map { |v| v[:field_label] || v[:field] }.join(", ")
  126. messages << "Datos de la organización faltantes: #{fields}"
  127. end
  128. if by_source["third_party"]&.any?
  129. fields = by_source["third_party"].map { |v| v[:field_label] || v[:field] }.join(", ")
  130. messages << "Datos del tercero faltantes: #{fields}"
  131. end
  132. if by_source["contract"]&.any?
  133. fields = by_source["contract"].map { |v| v[:field_label] || v[:field] }.join(", ")
  134. messages << "Datos del contrato faltantes: #{fields}"
  135. end
  136. if by_source[nil]&.any?
  137. vars_list = by_source[nil].map { |v| v[:variable] }.join(", ")
  138. messages << "Variables sin mapeo: #{vars_list}"
  139. end
  140. if by_source["custom"]&.any?
  141. vars_list = by_source["custom"].map { |v| v[:variable] }.join(", ")
  142. messages << "Variables personalizadas sin valor: #{vars_list}"
  143. end
  144. messages.join(". ")
  145. end
  146. def log_variables_to_replace
  147. Rails.logger.info "=== Document Generation: Variables to Replace ==="
  148. variable_values.each do |name, value|
  149. Rails.logger.info " {{#{name}}} => #{value.inspect}"
  150. end
  151. Rails.logger.info "================================================="
  152. end
  153. def generate_document!
  154. template_content = template.file_content
  155. raise GenerationError, "No se pudo leer el archivo del template" unless template_content
  156. # Create temp files
  157. input_file = Tempfile.new(["template", ".docx"])
  158. input_file.binmode
  159. input_file.write(template_content)
  160. input_file.close
  161. output_file = Tempfile.new(["output", ".docx"])
  162. output_file.close
  163. begin
  164. # Process the DOCX file (replace variables)
  165. process_docx(input_file.path, output_file.path)
  166. # Log replacement results
  167. log_replacement_results
  168. # Read the processed DOCX content
  169. docx_content = File.binread(output_file.path)
  170. # Try to convert to PDF
  171. pdf_content = convert_to_pdf(output_file.path)
  172. if pdf_content
  173. # PDF conversion successful - create complete document
  174. create_generated_document(pdf_content, docx_content: nil)
  175. else
  176. # PDF conversion failed - store DOCX for local sync
  177. Rails.logger.warn "PDF conversion failed. Storing DOCX for local sync workflow."
  178. create_generated_document_pending_pdf(docx_content)
  179. end
  180. ensure
  181. input_file.unlink
  182. output_file.unlink
  183. end
  184. end
  185. def process_docx(input_path, output_path)
  186. # Copy input to output first
  187. FileUtils.cp(input_path, output_path)
  188. Zip::File.open(output_path) do |zipfile|
  189. # Process main document
  190. process_xml_part(zipfile, "word/document.xml")
  191. # Process headers
  192. zipfile.glob("word/header*.xml").each do |entry|
  193. process_xml_part(zipfile, entry.name)
  194. end
  195. # Process footers
  196. zipfile.glob("word/footer*.xml").each do |entry|
  197. process_xml_part(zipfile, entry.name)
  198. end
  199. end
  200. end
  201. def process_xml_part(zipfile, entry_name)
  202. entry = zipfile.find_entry(entry_name)
  203. return unless entry
  204. xml_content = entry.get_input_stream.read
  205. doc = Nokogiri::XML(xml_content)
  206. # Process all paragraphs
  207. doc.xpath("//w:p", "w" => WORD_NAMESPACE).each do |paragraph|
  208. process_paragraph(paragraph)
  209. end
  210. # Write back
  211. zipfile.get_output_stream(entry_name) { |f| f.write(doc.to_xml) }
  212. end
  213. def process_paragraph(paragraph)
  214. # Get all text runs in this paragraph
  215. runs = paragraph.xpath(".//w:r", "w" => WORD_NAMESPACE)
  216. return if runs.empty?
  217. # Collect all text content to find variables
  218. full_text = runs.map { |r| get_run_text(r) }.join
  219. # Find all variables in the combined text
  220. variables_found = full_text.scan(VARIABLE_PATTERN).flatten
  221. return if variables_found.empty?
  222. # For each variable found, we need to handle the replacement
  223. # This is complex because the variable might span multiple runs
  224. variables_found.each do |var_name|
  225. pattern = "{{#{var_name}}}"
  226. # Find the matching variable in variable_values using normalized comparison
  227. replacement = find_variable_value(var_name)
  228. if replacement.nil?
  229. @replacement_log << { variable: var_name, status: "not_found", reason: "No mapping found" }
  230. next
  231. end
  232. # Try to replace in the consolidated text of the paragraph
  233. replace_variable_in_paragraph(paragraph, pattern, replacement.to_s)
  234. @replacement_log << { variable: var_name, status: "replaced", value: replacement.to_s }
  235. end
  236. end
  237. # Find variable value using normalized comparison
  238. # This allows matching "NOMBRE DEL TRABAJADOR" with "Nombre del Trabajador"
  239. def find_variable_value(var_name)
  240. # First try exact match
  241. return variable_values[var_name] if variable_values.key?(var_name)
  242. # Then try normalized comparison
  243. normalized_var = normalize_for_comparison(var_name)
  244. variable_values.each do |key, value|
  245. return value if normalize_for_comparison(key) == normalized_var
  246. end
  247. nil
  248. end
  249. # Normalize a string for comparison (lowercase, no accents)
  250. def normalize_for_comparison(str)
  251. # Remove accents and convert to lowercase
  252. str.to_s
  253. .unicode_normalize(:nfd)
  254. .gsub(/[\u0300-\u036f]/, "")
  255. .downcase
  256. .strip
  257. end
  258. def get_run_text(run)
  259. run.xpath(".//w:t", "w" => WORD_NAMESPACE).map(&:text).join
  260. end
  261. def replace_variable_in_paragraph(paragraph, pattern, replacement)
  262. runs = paragraph.xpath(".//w:r", "w" => WORD_NAMESPACE)
  263. return if runs.empty?
  264. # Strategy 1: Try simple replacement in each run
  265. runs.each do |run|
  266. text_nodes = run.xpath(".//w:t", "w" => WORD_NAMESPACE)
  267. text_nodes.each do |text_node|
  268. if text_node.text.include?(pattern)
  269. text_node.content = text_node.text.gsub(pattern, replacement)
  270. return true
  271. end
  272. end
  273. end
  274. # Strategy 2: Handle fragmented variables across runs
  275. # Collect text from all runs and find the variable position
  276. full_text = ""
  277. run_map = [] # [{run:, text_node:, start:, end:}]
  278. runs.each do |run|
  279. text_nodes = run.xpath(".//w:t", "w" => WORD_NAMESPACE)
  280. text_nodes.each do |text_node|
  281. start_pos = full_text.length
  282. full_text += text_node.text
  283. end_pos = full_text.length
  284. run_map << { run: run, text_node: text_node, start: start_pos, end: end_pos }
  285. end
  286. end
  287. # Find the variable in the full text
  288. var_start = full_text.index(pattern)
  289. return false unless var_start
  290. var_end = var_start + pattern.length
  291. # Find which runs contain the variable
  292. affected_nodes = run_map.select do |entry|
  293. # Node overlaps with variable position
  294. entry[:start] < var_end && entry[:end] > var_start
  295. end
  296. return false if affected_nodes.empty?
  297. if affected_nodes.length == 1
  298. # Variable is in a single node - simple replacement
  299. node = affected_nodes.first[:text_node]
  300. node.content = node.text.gsub(pattern, replacement)
  301. else
  302. # Variable spans multiple nodes
  303. # Put the replacement in the first node and clear the rest
  304. first_node = affected_nodes.first
  305. first_node_text = first_node[:text_node].text
  306. # Calculate what part of the pattern is in the first node
  307. pattern_start_in_node = [var_start - first_node[:start], 0].max
  308. pattern_end_in_node = [var_end - first_node[:start], first_node_text.length].min
  309. # Replace in first node
  310. new_text = first_node_text[0...pattern_start_in_node] + replacement
  311. remaining_text = first_node_text[pattern_end_in_node..]
  312. first_node[:text_node].content = new_text + (remaining_text || "")
  313. # Clear the parts of the variable from subsequent nodes
  314. affected_nodes[1..].each do |entry|
  315. node_text = entry[:text_node].text
  316. node_start = entry[:start]
  317. node_end = entry[:end]
  318. # Calculate what part of this node is part of the variable
  319. clear_start = [var_start - node_start, 0].max
  320. clear_end = [var_end - node_start, node_text.length].min
  321. # Keep text before and after the variable part
  322. new_content = node_text[0...clear_start].to_s + node_text[clear_end..].to_s
  323. entry[:text_node].content = new_content
  324. end
  325. end
  326. true
  327. end
  328. def log_replacement_results
  329. Rails.logger.info "=== Document Generation: Replacement Results ==="
  330. @replacement_log.each do |log|
  331. if log[:status] == "replaced"
  332. Rails.logger.info " ✓ {{#{log[:variable]}}} => #{log[:value]}"
  333. else
  334. Rails.logger.warn " ✗ {{#{log[:variable]}}} - #{log[:reason]}"
  335. end
  336. end
  337. Rails.logger.info "================================================"
  338. end
  339. def convert_to_pdf(docx_path)
  340. # Priority 1: LibreOffice (local, best quality)
  341. if libreoffice_available?
  342. result = convert_with_libreoffice(docx_path)
  343. return result if result
  344. end
  345. # Priority 2: Gotenberg API (LibreOffice via HTTP, preserves formatting)
  346. if gotenberg_available?
  347. result = convert_with_gotenberg(docx_path)
  348. return result if result
  349. end
  350. # Priority 3: Local PDF sync workflow (for Heroku deployment)
  351. # When LibreOffice and Gotenberg are unavailable, store DOCX for local conversion
  352. # This preserves formatting by generating PDF locally with LibreOffice
  353. # Use: rake db:sync:generate_pending_pdfs
  354. Rails.logger.info "LibreOffice/Gotenberg unavailable - using local PDF sync workflow"
  355. Rails.logger.info "Document will be created with 'pending' status. Run 'rake db:sync:generate_pending_pdfs' locally to generate PDF with proper formatting."
  356. nil
  357. end
  358. def gotenberg_available?
  359. ENV["GOTENBERG_URL"].present?
  360. end
  361. def convert_with_gotenberg(docx_path)
  362. Rails.logger.info "Converting DOCX to PDF using Gotenberg API..."
  363. begin
  364. require "net/http"
  365. require "uri"
  366. gotenberg_url = ENV["GOTENBERG_URL"].chomp("/")
  367. uri = URI.parse("#{gotenberg_url}/forms/libreoffice/convert")
  368. # Prepare multipart form data
  369. boundary = "----GotenbergBoundary#{SecureRandom.hex(8)}"
  370. file_content = File.binread(docx_path)
  371. file_name = File.basename(docx_path)
  372. body = build_multipart_body(boundary, file_name, file_content)
  373. http = Net::HTTP.new(uri.host, uri.port)
  374. http.use_ssl = uri.scheme == "https"
  375. http.read_timeout = 60
  376. http.open_timeout = 30
  377. request = Net::HTTP::Post.new(uri.request_uri)
  378. request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
  379. request.body = body
  380. response = http.request(request)
  381. if response.code == "200"
  382. Rails.logger.info "Gotenberg conversion successful (#{response.body.bytesize} bytes)"
  383. response.body
  384. else
  385. Rails.logger.error "Gotenberg conversion failed: #{response.code} - #{response.body}"
  386. nil
  387. end
  388. rescue StandardError => e
  389. Rails.logger.error "Gotenberg conversion error: #{e.message}"
  390. Rails.logger.error e.backtrace.first(3).join("\n")
  391. nil
  392. end
  393. end
  394. def build_multipart_body(boundary, file_name, file_content)
  395. body = ""
  396. body << "--#{boundary}\r\n"
  397. body << "Content-Disposition: form-data; name=\"files\"; filename=\"#{file_name}\"\r\n"
  398. body << "Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document\r\n"
  399. body << "\r\n"
  400. body << file_content
  401. body << "\r\n"
  402. body << "--#{boundary}--\r\n"
  403. body
  404. end
  405. def pandoc_available?
  406. @pandoc_path ||= `which pandoc 2>/dev/null`.strip.presence
  407. @pandoc_path ||= "/app/vendor/pandoc/bin/pandoc" if File.exist?("/app/vendor/pandoc/bin/pandoc")
  408. @pandoc_path.present?
  409. end
  410. def convert_with_pandoc_wkhtmltopdf(docx_path)
  411. Rails.logger.info "Converting DOCX to PDF using Pandoc + wkhtmltopdf..."
  412. begin
  413. # Step 1: Convert DOCX to HTML using Pandoc (using shell command directly)
  414. html_output = Tempfile.new(["pandoc_output", ".html"])
  415. html_output.close
  416. pandoc_cmd = "pandoc -f docx -t html5 --standalone \"#{docx_path}\" -o \"#{html_output.path}\" 2>&1"
  417. Rails.logger.info "Running: #{pandoc_cmd}"
  418. result = `#{pandoc_cmd}`
  419. unless $?.success?
  420. Rails.logger.error "Pandoc failed: #{result}"
  421. html_output.unlink
  422. return nil
  423. end
  424. html_content = File.read(html_output.path)
  425. html_output.unlink
  426. Rails.logger.info "Pandoc DOCX->HTML conversion successful (#{html_content.bytesize} bytes)"
  427. # Inject additional styles into the pandoc-generated HTML
  428. styled_html = inject_pdf_styles(html_content)
  429. # Step 2: Convert HTML to PDF using wkhtmltopdf
  430. pdf_content = WickedPdf.new.pdf_from_string(
  431. styled_html,
  432. page_size: "Letter",
  433. margin: { top: 20, bottom: 20, left: 20, right: 20 },
  434. encoding: "UTF-8"
  435. )
  436. Rails.logger.info "wkhtmltopdf HTML->PDF conversion successful (#{pdf_content.bytesize} bytes)"
  437. pdf_content
  438. rescue StandardError => e
  439. Rails.logger.error "Pandoc + wkhtmltopdf conversion failed: #{e.message}"
  440. Rails.logger.error e.backtrace.first(5).join("\n")
  441. nil
  442. end
  443. end
  444. def inject_pdf_styles(html_content)
  445. # Inject additional CSS styles into pandoc-generated HTML
  446. additional_styles = <<~CSS
  447. <style>
  448. body {
  449. font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  450. font-size: 11pt;
  451. line-height: 1.4;
  452. color: #333;
  453. max-width: 100%;
  454. }
  455. h1, h2, h3, h4, h5, h6 {
  456. color: #222;
  457. margin-top: 0.8em;
  458. margin-bottom: 0.4em;
  459. }
  460. p {
  461. margin: 0.4em 0;
  462. text-align: justify;
  463. }
  464. table {
  465. border-collapse: collapse;
  466. width: 100%;
  467. margin: 0.8em 0;
  468. }
  469. th, td {
  470. border: 1px solid #999;
  471. padding: 6px;
  472. text-align: left;
  473. }
  474. th {
  475. background-color: #f0f0f0;
  476. font-weight: bold;
  477. }
  478. </style>
  479. CSS
  480. # Insert styles before </head>
  481. if html_content.include?("</head>")
  482. html_content.sub("</head>", "#{additional_styles}</head>")
  483. else
  484. # If no head tag, wrap the content
  485. <<~HTML
  486. <!DOCTYPE html>
  487. <html>
  488. <head>
  489. <meta charset="UTF-8">
  490. #{additional_styles}
  491. </head>
  492. <body>
  493. #{html_content}
  494. </body>
  495. </html>
  496. HTML
  497. end
  498. end
  499. def libreoffice_available?
  500. # Check common LibreOffice paths on macOS, Linux, and Heroku
  501. paths_to_check = [
  502. "/app/.apt/usr/bin/soffice", # Heroku apt buildpack path
  503. "/Applications/LibreOffice.app/Contents/MacOS/soffice",
  504. "/usr/local/bin/soffice",
  505. "/usr/bin/soffice",
  506. "/usr/bin/libreoffice"
  507. ]
  508. @libreoffice_path = paths_to_check.find { |p| File.exist?(p) }
  509. @libreoffice_path ||= `which soffice 2>/dev/null`.strip.presence
  510. @libreoffice_path ||= `which libreoffice 2>/dev/null`.strip.presence
  511. Rails.logger.info "LibreOffice path: #{@libreoffice_path || 'NOT FOUND'}"
  512. @libreoffice_path.present?
  513. end
  514. def convert_with_libreoffice(docx_path)
  515. output_dir = Dir.mktmpdir
  516. user_profile = Dir.mktmpdir("lo_profile")
  517. begin
  518. # Set environment for Heroku apt buildpack
  519. lib_path = "/app/.apt/usr/lib/libreoffice/program:/app/.apt/usr/lib/x86_64-linux-gnu"
  520. # Additional environment variables to fix LibreOffice issues on Heroku
  521. env_vars = {
  522. "LD_LIBRARY_PATH" => "#{lib_path}:#{ENV['LD_LIBRARY_PATH']}",
  523. "HOME" => "/tmp",
  524. "FONTCONFIG_PATH" => "/etc/fonts",
  525. "SAL_DISABLE_SYNCHRONOUS_PRINTER_DETECTION" => "1",
  526. "SAL_DISABLE_COMPONENTITHREADING" => "1",
  527. "SAL_USE_VCLPLUGIN" => "svp",
  528. "DISPLAY" => "",
  529. "URE_BOOTSTRAP" => "file:///app/.apt/usr/lib/libreoffice/program/fundamentalrc"
  530. }
  531. env_string = env_vars.map { |k, v| "#{k}=#{v}" }.join(" ")
  532. # Use -env:UserInstallation to avoid profile issues
  533. user_install = "-env:UserInstallation=file://#{user_profile}"
  534. cmd = "#{env_string} \"#{@libreoffice_path}\" --headless --nologo --nofirststartwizard --norestore #{user_install} --convert-to pdf --outdir \"#{output_dir}\" \"#{docx_path}\" 2>&1"
  535. Rails.logger.info "Running LibreOffice: #{cmd}"
  536. result = `#{cmd}`
  537. Rails.logger.info "LibreOffice conversion result: #{result}"
  538. pdf_files = Dir.glob(File.join(output_dir, "*.pdf"))
  539. if pdf_files.empty?
  540. Rails.logger.error "LibreOffice conversion failed"
  541. return nil
  542. end
  543. File.binread(pdf_files.first)
  544. ensure
  545. FileUtils.rm_rf(output_dir)
  546. FileUtils.rm_rf(user_profile)
  547. end
  548. end
  549. def convert_using_preview_with_overlay(_docx_path)
  550. require "hexapdf"
  551. require "combine_pdf"
  552. preview_content = template.preview_content
  553. return nil unless preview_content
  554. Rails.logger.info "Attempting PDF text replacement using HexaPDF..."
  555. begin
  556. # Try to replace variables directly in the PDF using HexaPDF's text search
  557. doc = HexaPDF::Document.new(io: StringIO.new(preview_content))
  558. replacements_made = 0
  559. variable_values.each do |var_name, value|
  560. # Try different placeholder formats
  561. placeholders = [
  562. "{{#{var_name}}}",
  563. "{{ #{var_name} }}",
  564. "{{#{var_name.upcase}}}",
  565. "{{#{var_name.downcase}}}"
  566. ]
  567. doc.pages.each do |page|
  568. # Get the page's content stream
  569. contents = page.contents
  570. next unless contents
  571. # Decode the content stream to get raw data
  572. data = contents.stream rescue nil
  573. next unless data
  574. data_str = data.to_s.force_encoding("UTF-8") rescue data.to_s
  575. placeholders.each do |placeholder|
  576. if data_str.include?(placeholder)
  577. data_str.gsub!(placeholder, value.to_s)
  578. replacements_made += 1
  579. Rails.logger.info " Replaced '#{placeholder}' with '#{value}'"
  580. end
  581. end
  582. # Update the content stream if changes were made
  583. if replacements_made > 0
  584. contents.stream = data_str
  585. end
  586. end
  587. end
  588. if replacements_made > 0
  589. Rails.logger.info "HexaPDF: Made #{replacements_made} replacements successfully"
  590. output = StringIO.new
  591. doc.write(output)
  592. return output.string
  593. else
  594. Rails.logger.info "HexaPDF: No direct replacements possible, using data summary page"
  595. end
  596. rescue StandardError => e
  597. Rails.logger.warn "HexaPDF replacement failed: #{e.message}, falling back to data summary"
  598. end
  599. # Fallback: Use original preview with data summary page
  600. convert_using_stored_preview
  601. end
  602. def convert_using_stored_preview
  603. require "combine_pdf"
  604. # Get the stored PDF preview (has original formatting but with placeholders)
  605. preview_content = template.preview_content
  606. raise GenerationError, "No se pudo leer el PDF preview" unless preview_content
  607. # Parse the preview PDF
  608. base_pdf = CombinePDF.parse(preview_content)
  609. # Create a data summary page with all variable values
  610. data_page_pdf = create_data_summary_page(base_pdf)
  611. # Add data summary as the first page
  612. if data_page_pdf
  613. data_pages = CombinePDF.parse(data_page_pdf)
  614. combined = CombinePDF.new
  615. data_pages.pages.each { |page| combined << page }
  616. base_pdf.pages.each { |page| combined << page }
  617. return combined.to_pdf
  618. end
  619. base_pdf.to_pdf
  620. end
  621. def create_data_summary_page(base_pdf)
  622. return nil if variable_values.empty?
  623. # Get page dimensions from first page
  624. first_page = base_pdf.pages.first
  625. page_width = first_page.mediabox[2] || 612
  626. page_height = first_page.mediabox[3] || 792
  627. employee = context[:employee]
  628. org = context[:organization]
  629. Prawn::Document.new(
  630. page_size: [page_width, page_height],
  631. margin: 40
  632. ) do |pdf|
  633. # Header
  634. pdf.text "DATOS DEL DOCUMENTO", size: 16, style: :bold, align: :center
  635. pdf.move_down 5
  636. pdf.text "Generado: #{Time.current.strftime('%d/%m/%Y %H:%M')}", size: 9, align: :center, color: "666666"
  637. pdf.move_down 20
  638. # Employee info box
  639. if employee
  640. pdf.text "DATOS DEL EMPLEADO", size: 12, style: :bold
  641. pdf.stroke_horizontal_rule
  642. pdf.move_down 10
  643. employee_data = [
  644. ["Nombre:", employee.full_name],
  645. ["Identificacion:", "#{employee.identification_type} #{employee.identification_number}"],
  646. ["Cargo:", employee.job_title],
  647. ["Departamento:", employee.department],
  648. ["Fecha de Ingreso:", employee.hire_date&.strftime("%d/%m/%Y")],
  649. ["Tipo de Contrato:", format_contract_type(employee.contract_type)],
  650. ["Salario:", format_currency(employee.salary)]
  651. ].reject { |_, v| v.blank? }
  652. pdf.table(employee_data, width: pdf.bounds.width, cell_style: { size: 10, padding: 5 }) do |t|
  653. t.columns(0).font_style = :bold
  654. t.columns(0).width = 150
  655. end
  656. pdf.move_down 20
  657. end
  658. # Variable values
  659. pdf.text "VARIABLES DEL DOCUMENTO", size: 12, style: :bold
  660. pdf.stroke_horizontal_rule
  661. pdf.move_down 10
  662. var_data = variable_values.map { |name, value| [name, value.to_s] }
  663. unless var_data.empty?
  664. pdf.table(var_data, width: pdf.bounds.width, cell_style: { size: 9, padding: 4 }) do |t|
  665. t.columns(0).font_style = :bold
  666. t.columns(0).width = 180
  667. end
  668. end
  669. # Footer note
  670. pdf.move_down 30
  671. pdf.text "NOTA: Los datos anteriores corresponden a las variables reemplazadas en el documento.",
  672. size: 8, color: "666666", align: :center
  673. pdf.text "El formato original del documento se muestra en las paginas siguientes.",
  674. size: 8, color: "666666", align: :center
  675. end.render
  676. rescue => e
  677. Rails.logger.error "Error creating data summary page: #{e.message}"
  678. nil
  679. end
  680. def generate_basic_prawn_pdf(docx_path)
  681. doc = Docx::Document.open(docx_path)
  682. Prawn::Document.new(page_size: "LETTER", margin: 50) do |pdf|
  683. setup_fonts(pdf)
  684. doc.paragraphs.each do |para|
  685. text = para.text.strip
  686. next if text.empty?
  687. render_paragraph(pdf, para, text)
  688. end
  689. doc.tables.each do |table|
  690. render_table(pdf, table)
  691. end
  692. end.render
  693. end
  694. def setup_fonts(pdf)
  695. # Try to use system fonts or fallback
  696. font_path = Rails.root.join("app/assets/fonts")
  697. if File.exist?(font_path.join("DejaVuSans.ttf"))
  698. pdf.font_families.update(
  699. "DejaVu" => {
  700. normal: font_path.join("DejaVuSans.ttf").to_s,
  701. bold: font_path.join("DejaVuSans-Bold.ttf").to_s,
  702. italic: font_path.join("DejaVuSans-Oblique.ttf").to_s
  703. }
  704. )
  705. pdf.font("DejaVu")
  706. end
  707. rescue StandardError => e
  708. Rails.logger.warn "Could not load custom fonts: #{e.message}"
  709. end
  710. def render_paragraph(pdf, para, text)
  711. # Detect heading style
  712. style_name = para.node.at_xpath(".//w:pStyle/@w:val", "w" => WORD_NAMESPACE)&.value || ""
  713. options = { size: 11, leading: 4 }
  714. if style_name.downcase.include?("heading") || style_name.downcase.include?("titulo")
  715. options[:size] = 14
  716. options[:style] = :bold
  717. pdf.move_down 10
  718. elsif style_name.downcase.include?("title")
  719. options[:size] = 18
  720. options[:style] = :bold
  721. pdf.move_down 15
  722. end
  723. pdf.text text, options
  724. pdf.move_down 6
  725. rescue Prawn::Errors::IncompatibleStringEncoding
  726. # Handle encoding issues
  727. pdf.text text.encode("UTF-8", invalid: :replace, undef: :replace), options
  728. pdf.move_down 6
  729. end
  730. def render_table(pdf, table)
  731. table_data = table.rows.map do |row|
  732. row.cells.map do |cell|
  733. cell.paragraphs.map(&:text).join("\n")
  734. end
  735. end
  736. return if table_data.empty? || table_data.all?(&:empty?)
  737. pdf.move_down 10
  738. pdf.table(table_data, width: pdf.bounds.width) do
  739. cells.padding = 8
  740. cells.border_width = 0.5
  741. cells.border_color = "666666"
  742. row(0).font_style = :bold
  743. row(0).background_color = "EEEEEE"
  744. end
  745. pdf.move_down 10
  746. rescue StandardError => e
  747. Rails.logger.warn "Error rendering table: #{e.message}"
  748. end
  749. def format_contract_type(type)
  750. return nil if type.blank?
  751. types = {
  752. "indefinite" => "Término Indefinido",
  753. "fixed_term" => "Término Fijo",
  754. "work_or_labor" => "Obra o Labor",
  755. "temporary" => "Temporal",
  756. "apprenticeship" => "Aprendizaje"
  757. }
  758. types[type.to_s] || type.to_s.humanize
  759. end
  760. def format_currency(amount)
  761. return nil if amount.blank?
  762. "$#{number_with_delimiter(amount.to_i)}"
  763. end
  764. def number_with_delimiter(number, delimiter: ".")
  765. number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1' + delimiter).reverse
  766. end
  767. def create_generated_document(pdf_content, docx_content: nil)
  768. file_name = "#{template.name.parameterize}-#{Time.current.strftime('%Y%m%d%H%M%S')}.pdf"
  769. pdf_file = Mongoid::GridFs.put(
  770. StringIO.new(pdf_content),
  771. filename: file_name,
  772. content_type: "application/pdf"
  773. )
  774. generated_doc = GeneratedDocument.create!(
  775. name: file_name,
  776. template: template,
  777. organization: context[:organization],
  778. requested_by: context[:user],
  779. draft_file_id: pdf_file.id,
  780. file_name: file_name,
  781. variable_values: variable_values,
  782. source: context[:request],
  783. employee: context[:employee],
  784. pdf_generation_status: "completed"
  785. )
  786. generated_doc.initialize_signatures!
  787. generated_doc
  788. end
  789. def create_generated_document_pending_pdf(docx_content)
  790. file_name = "#{template.name.parameterize}-#{Time.current.strftime('%Y%m%d%H%M%S')}"
  791. # Store DOCX in GridFS for later local conversion
  792. docx_file = Mongoid::GridFs.put(
  793. StringIO.new(docx_content),
  794. filename: "#{file_name}.docx",
  795. content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  796. )
  797. generated_doc = GeneratedDocument.create!(
  798. name: "#{file_name}.pdf",
  799. template: template,
  800. organization: context[:organization],
  801. requested_by: context[:user],
  802. docx_file_id: docx_file.id,
  803. file_name: "#{file_name}.pdf",
  804. variable_values: variable_values,
  805. source: context[:request],
  806. employee: context[:employee],
  807. pdf_generation_status: "pending"
  808. )
  809. Rails.logger.info "Document created with pending PDF generation: #{generated_doc.uuid}"
  810. generated_doc
  811. end
  812. class GenerationError < StandardError; end
  813. # Custom error for missing variables with detailed info
  814. class MissingVariablesError < StandardError
  815. attr_reader :missing_data
  816. def initialize(message, missing_data)
  817. super(message)
  818. @missing_data = missing_data
  819. end
  820. end
  821. end
  822. end

app/services/templates/signature_renderer_service.rb

0.0% lines covered

90 relevant lines. 0 lines covered and 90 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class SignatureRendererService
  4. FONT_PATH_MAP = {
  5. "Allura" => "Allura-Regular",
  6. "Dancing Script" => "DancingScript-Regular",
  7. "Great Vibes" => "GreatVibes-Regular",
  8. "Pacifico" => "Pacifico-Regular",
  9. "Sacramento" => "Sacramento-Regular"
  10. }.freeze
  11. def initialize(signature)
  12. @signature = signature
  13. end
  14. # Render styled signature as base64 PNG
  15. def render_styled
  16. return @signature.image_data if @signature.drawn?
  17. text = @signature.styled_text
  18. font = FONT_PATH_MAP[@signature.font_family] || "Helvetica"
  19. color = @signature.font_color&.delete("#") || "000000"
  20. size = @signature.font_size || 48
  21. # Create signature image using MiniMagick
  22. image = MiniMagick::Image.open(transparent_base_image)
  23. image.combine_options do |c|
  24. c.font font_path(font)
  25. c.pointsize size.to_s
  26. c.fill "##{color}"
  27. c.gravity "Center"
  28. c.draw "text 0,0 '#{escape_text(text)}'"
  29. end
  30. # Trim whitespace and add padding
  31. image.trim
  32. image.border "10x10"
  33. image.bordercolor "transparent"
  34. # Convert to base64
  35. Base64.strict_encode64(image.to_blob)
  36. rescue StandardError => e
  37. Rails.logger.error "SignatureRenderer error: #{e.message}"
  38. generate_fallback_signature
  39. end
  40. # Render a drawn signature from base64 data
  41. def render_drawn
  42. @signature.image_data
  43. end
  44. # Render signature to a temporary file for PDF embedding
  45. def to_tempfile
  46. data = @signature.drawn? ? @signature.image_data : render_styled
  47. # Remove data URI prefix if present
  48. base64_data = data.sub(/^data:image\/\w+;base64,/, "")
  49. tempfile = Tempfile.new(["signature", ".png"])
  50. tempfile.binmode
  51. tempfile.write(Base64.decode64(base64_data))
  52. tempfile.rewind
  53. tempfile
  54. end
  55. private
  56. def transparent_base_image
  57. # Create a transparent 400x150 PNG base using tempfile
  58. @base_tempfile = Tempfile.new(["base", ".png"])
  59. MiniMagick::Tool::Convert.new do |convert|
  60. convert << "-size" << "400x150"
  61. convert << "xc:transparent"
  62. convert << @base_tempfile.path
  63. end
  64. @base_tempfile.path
  65. end
  66. def font_path(font_name)
  67. # Check for Google Fonts in common locations
  68. possible_paths = [
  69. Rails.root.join("app", "assets", "fonts", "#{font_name}.ttf"),
  70. "/usr/share/fonts/truetype/google-fonts/#{font_name}.ttf",
  71. "/Library/Fonts/#{font_name}.ttf",
  72. "~/Library/Fonts/#{font_name}.ttf"
  73. ]
  74. possible_paths.find { |p| File.exist?(p.to_s) } || font_name
  75. end
  76. def escape_text(text)
  77. text.to_s.gsub("'", "\\\\'")
  78. end
  79. def generate_fallback_signature
  80. # Generate a simple fallback signature using ImageMagick
  81. text = @signature.styled_text || "Signature"
  82. color = @signature.font_color&.delete("#") || "000000"
  83. tempfile = Tempfile.new(["fallback", ".png"])
  84. MiniMagick::Tool::Convert.new do |convert|
  85. convert << "-size" << "400x150"
  86. convert << "xc:transparent"
  87. convert << "-font" << "Helvetica-Oblique"
  88. convert << "-pointsize" << "36"
  89. convert << "-fill" << "##{color}"
  90. convert << "-gravity" << "Center"
  91. convert << "-draw" << "text 0,0 '#{escape_text(text)}'"
  92. convert << tempfile.path
  93. end
  94. image = MiniMagick::Image.open(tempfile.path)
  95. Base64.strict_encode64(image.to_blob)
  96. ensure
  97. tempfile&.close
  98. tempfile&.unlink
  99. end
  100. end
  101. end

app/services/templates/template_parser_service.rb

0.0% lines covered

48 relevant lines. 0 lines covered and 48 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class TemplateParserService
  4. VARIABLE_PATTERN = /\{\{([^}]+)\}\}/
  5. def initialize(file_content)
  6. @file_content = file_content
  7. end
  8. # Extract all {{Variable}} patterns from a Word document
  9. def extract_variables
  10. return [] unless @file_content
  11. # Write content to temp file for docx gem
  12. tempfile = Tempfile.new(["template", ".docx"])
  13. tempfile.binmode
  14. tempfile.write(@file_content)
  15. tempfile.rewind
  16. begin
  17. doc = Docx::Document.open(tempfile.path)
  18. variables = Set.new
  19. # Extract from paragraphs
  20. doc.paragraphs.each do |para|
  21. extract_from_text(para.text, variables)
  22. end
  23. # Extract from tables
  24. doc.tables.each do |table|
  25. table.rows.each do |row|
  26. row.cells.each do |cell|
  27. cell.paragraphs.each do |para|
  28. extract_from_text(para.text, variables)
  29. end
  30. end
  31. end
  32. end
  33. variables.to_a.sort
  34. rescue StandardError => e
  35. Rails.logger.error "TemplateParser error: #{e.message}"
  36. []
  37. ensure
  38. tempfile.close
  39. tempfile.unlink
  40. end
  41. end
  42. private
  43. def extract_from_text(text, variables)
  44. return unless text
  45. text.scan(VARIABLE_PATTERN).each do |match|
  46. variable_name = match[0].strip
  47. next if variable_name.blank?
  48. # Normalize to uppercase without accents for consistent matching
  49. normalized_name = VariableNormalizer.normalize(variable_name)
  50. variables.add(normalized_name)
  51. end
  52. end
  53. end
  54. end

app/services/templates/variable_normalizer.rb

0.0% lines covered

60 relevant lines. 0 lines covered and 60 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. # Normalizes variable names for consistent matching regardless of
  4. # case, accents, or special characters
  5. class VariableNormalizer
  6. # Mapping of accented characters to their base form (lowercase)
  7. ACCENT_MAP_LOWER = {
  8. "á" => "a", "à" => "a", "ä" => "a", "â" => "a", "ã" => "a",
  9. "é" => "e", "è" => "e", "ë" => "e", "ê" => "e",
  10. "í" => "i", "ì" => "i", "ï" => "i", "î" => "i",
  11. "ó" => "o", "ò" => "o", "ö" => "o", "ô" => "o", "õ" => "o",
  12. "ú" => "u", "ù" => "u", "ü" => "u", "û" => "u",
  13. "ñ" => "n",
  14. "ç" => "c"
  15. }.freeze
  16. # Mapping of accented characters to their base form (uppercase)
  17. ACCENT_MAP_UPPER = {
  18. "Á" => "A", "À" => "A", "Ä" => "A", "Â" => "A", "Ã" => "A",
  19. "É" => "E", "È" => "E", "Ë" => "E", "Ê" => "E",
  20. "Í" => "I", "Ì" => "I", "Ï" => "I", "Î" => "I",
  21. "Ó" => "O", "Ò" => "O", "Ö" => "O", "Ô" => "O", "Õ" => "O",
  22. "Ú" => "U", "Ù" => "U", "Ü" => "U", "Û" => "U",
  23. "Ñ" => "N",
  24. "Ç" => "C"
  25. }.freeze
  26. # Words that should remain lowercase in Title Case (Spanish)
  27. LOWERCASE_WORDS = %w[de del la el los las a en con por para y o u].freeze
  28. class << self
  29. # Normalize a variable name to Title Case without accents
  30. # Example: "AUXILIO DE ALIMENTACIÓN" -> "Auxilio de Alimentacion"
  31. # @param name [String] The variable name to normalize
  32. # @return [String] Normalized variable name in Title Case
  33. def normalize(name)
  34. return "" if name.blank?
  35. result = name.to_s.strip
  36. # Replace accented characters (both cases)
  37. ACCENT_MAP_LOWER.each { |accented, base| result = result.gsub(accented, base) }
  38. ACCENT_MAP_UPPER.each { |accented, base| result = result.gsub(accented, base) }
  39. # Split by spaces and other separators, preserving the separators
  40. # This handles cases like "Dia/Mes/Ano"
  41. parts = result.split(/(\s+|[\/\-])/)
  42. parts.map.with_index do |part, index|
  43. # Skip separators
  44. next part if part.match?(/^[\s\/\-]+$/)
  45. word = part.downcase
  46. # Find the first real word index (skip separators)
  47. first_word_index = parts.index { |p| !p.match?(/^[\s\/\-]+$/) }
  48. is_first_word = (index == first_word_index)
  49. # First word is always capitalized, others check against lowercase list
  50. if is_first_word || !LOWERCASE_WORDS.include?(word)
  51. word.capitalize
  52. else
  53. word
  54. end
  55. end.join
  56. end
  57. # Generate a key-safe version (lowercase, underscores)
  58. # @param name [String] The variable name
  59. # @return [String] Key-safe string for use in mapping keys
  60. def to_key(name)
  61. return "" if name.blank?
  62. result = name.to_s.strip.downcase
  63. # Replace accented characters
  64. ACCENT_MAP_LOWER.each { |accented, base| result = result.gsub(accented, base) }
  65. result
  66. .gsub(/[^a-z0-9]+/, "_")
  67. .gsub(/^_+|_+$/, "")
  68. end
  69. # Get the comparison key (for matching, all lowercase without accents)
  70. # @param name [String] The variable name
  71. # @return [String] Lowercase string for comparison
  72. def comparison_key(name)
  73. return "" if name.blank?
  74. result = name.to_s.strip.downcase
  75. # Replace accented characters
  76. ACCENT_MAP_LOWER.each { |accented, base| result = result.gsub(accented, base) }
  77. # Normalize spaces
  78. result.gsub(/\s+/, " ").strip
  79. end
  80. # Check if two variable names are equivalent after normalization
  81. # @param name1 [String] First variable name
  82. # @param name2 [String] Second variable name
  83. # @return [Boolean] True if names match after normalization
  84. def equivalent?(name1, name2)
  85. comparison_key(name1) == comparison_key(name2)
  86. end
  87. end
  88. end
  89. end

app/services/templates/variable_resolver_service.rb

0.0% lines covered

588 relevant lines. 0 lines covered and 588 lines missed.
    
  1. # frozen_string_literal: true
  2. module Templates
  3. class VariableResolverService
  4. def initialize(context)
  5. @employee = context[:employee]
  6. @organization = context[:organization]
  7. @request = context[:request]
  8. @third_party = context[:third_party]
  9. @contract = context[:contract]
  10. @custom_values = context[:custom_values] || {}
  11. end
  12. # Resolve a single variable path to its value
  13. def resolve(variable_path)
  14. return @custom_values[variable_path] if @custom_values.key?(variable_path)
  15. parts = variable_path.split(".")
  16. source = parts.first
  17. field = parts[1..].join(".")
  18. case source
  19. when "employee"
  20. resolve_employee_field(field)
  21. when "organization"
  22. resolve_organization_field(field)
  23. when "request"
  24. resolve_request_field(field)
  25. when "third_party"
  26. resolve_third_party_field(field)
  27. when "contract"
  28. resolve_contract_field(field)
  29. when "system"
  30. resolve_system_field(field)
  31. when "custom"
  32. resolve_custom_field(field)
  33. else
  34. nil
  35. end
  36. end
  37. # Resolve all variables in a mapping hash
  38. def resolve_all(variable_mappings)
  39. result = {}
  40. variable_mappings.each do |variable_name, variable_path|
  41. result[variable_name] = resolve(variable_path)
  42. end
  43. result
  44. end
  45. # Get all resolved values ready for template substitution
  46. def resolve_for_template(template)
  47. result = {}
  48. template.variables.each do |variable_name|
  49. path = template.variable_mappings[variable_name]
  50. result[variable_name] = path ? resolve(path) : nil
  51. end
  52. result
  53. end
  54. # Validate all template variables and return missing ones
  55. # Returns { valid: true/false, missing: [...], resolved: {...} }
  56. def validate_for_template(template)
  57. resolved = {}
  58. missing = []
  59. template.variables.each do |variable_name|
  60. path = template.variable_mappings[variable_name]
  61. if path
  62. value = resolve(path)
  63. if value.present?
  64. resolved[variable_name] = value
  65. else
  66. missing << {
  67. variable: variable_name,
  68. path: path,
  69. source: path.split(".").first,
  70. field: path.split(".")[1..].join("."),
  71. label: friendly_label_for(path)
  72. }
  73. end
  74. else
  75. missing << {
  76. variable: variable_name,
  77. path: nil,
  78. source: "unmapped",
  79. field: nil,
  80. label: "Variable sin mapear: #{variable_name}"
  81. }
  82. end
  83. end
  84. {
  85. valid: missing.empty?,
  86. total_variables: template.variables.size,
  87. resolved_count: resolved.size,
  88. missing_count: missing.size,
  89. missing: missing,
  90. resolved: resolved
  91. }
  92. end
  93. private
  94. # Get a friendly label for a variable path
  95. def friendly_label_for(path)
  96. return "Variable desconocida" unless path
  97. parts = path.split(".")
  98. source = parts.first
  99. field = parts[1..].join(".")
  100. source_labels = {
  101. "third_party" => "Tercero",
  102. "contract" => "Contrato",
  103. "organization" => "Organización",
  104. "employee" => "Empleado",
  105. "system" => "Sistema"
  106. }
  107. field_labels = {
  108. # Third party
  109. "legal_rep_name" => "Nombre del representante legal",
  110. "legal_rep_id" => "Cédula del representante legal",
  111. "legal_rep_email" => "Email del representante legal",
  112. "display_name" => "Nombre",
  113. "business_name" => "Razón social",
  114. "identification_number" => "Número de identificación",
  115. "identification_type" => "Tipo de identificación",
  116. "address" => "Dirección",
  117. "city" => "Ciudad",
  118. "phone" => "Teléfono",
  119. "email" => "Email",
  120. "bank_name" => "Banco",
  121. "bank_account_number" => "Número de cuenta",
  122. "bank_account_type" => "Tipo de cuenta",
  123. # Contract
  124. "contract_number" => "Número de contrato",
  125. "title" => "Título",
  126. "amount" => "Monto",
  127. "amount_text" => "Monto en letras",
  128. "start_date" => "Fecha de inicio",
  129. "end_date" => "Fecha de fin",
  130. "description" => "Descripción",
  131. "duration_text" => "Duración",
  132. # Organization
  133. "name" => "Nombre",
  134. "tax_id" => "NIT",
  135. "nit" => "NIT"
  136. }
  137. source_label = source_labels[source] || source.humanize
  138. field_label = field_labels[field] || field.humanize
  139. "#{source_label}: #{field_label}"
  140. end
  141. def resolve_employee_field(field)
  142. return nil unless @employee
  143. case field
  144. when "full_name"
  145. @employee.full_name
  146. when "first_name"
  147. @employee.user&.first_name
  148. when "last_name"
  149. @employee.user&.last_name
  150. when "employee_number"
  151. @employee.employee_number
  152. when "job_title"
  153. @employee.job_title
  154. when "department"
  155. @employee.department
  156. when "hire_date"
  157. format_date(@employee.hire_date)
  158. when "hire_date_text"
  159. format_date_text(@employee.hire_date)
  160. when "identification_number"
  161. @employee.identification_number
  162. when "identification_type"
  163. @employee.identification_type
  164. when "email"
  165. @employee.user&.email
  166. when "years_of_service"
  167. calculate_years_of_service
  168. when "years_of_service_text"
  169. format_years_of_service
  170. # Compensation fields
  171. when "salary"
  172. format_currency(@employee.salary)
  173. when "salary_text"
  174. number_to_words(@employee.salary)
  175. when "food_allowance"
  176. format_currency(@employee.food_allowance)
  177. when "food_allowance_text"
  178. number_to_words(@employee.food_allowance)
  179. when "transport_allowance"
  180. format_currency(@employee.transport_allowance)
  181. when "transport_allowance_text"
  182. number_to_words(@employee.transport_allowance)
  183. when "total_compensation"
  184. total = (@employee.salary || 0) + (@employee.food_allowance || 0) + (@employee.transport_allowance || 0)
  185. format_currency(total)
  186. when "total_compensation_text"
  187. total = (@employee.salary || 0) + (@employee.food_allowance || 0) + (@employee.transport_allowance || 0)
  188. number_to_words(total)
  189. when "payment_frequency"
  190. format_payment_frequency(@employee.payment_frequency)
  191. when "work_city"
  192. @employee.work_city
  193. # Contract fields
  194. when "contract_type"
  195. format_contract_type(@employee.contract_type)
  196. when "contract_start_date"
  197. format_date(@employee.contract_start_date || @employee.hire_date)
  198. when "contract_end_date"
  199. format_date(@employee.contract_end_date)
  200. when "contract_duration"
  201. format_contract_duration
  202. when "trial_period_days"
  203. @employee.trial_period_days.to_s
  204. # Personal data
  205. when "address"
  206. @employee.address
  207. when "phone"
  208. @employee.phone
  209. when "place_of_birth"
  210. @employee.place_of_birth
  211. when "nationality"
  212. @employee.nationality
  213. when "date_of_birth"
  214. format_date(@employee.date_of_birth)
  215. else
  216. @employee.try(field)
  217. end
  218. end
  219. def resolve_organization_field(field)
  220. return nil unless @organization
  221. case field
  222. when "name"
  223. @organization.name
  224. when "tax_id", "nit"
  225. @organization.settings&.dig("tax_id") || @organization.try(:tax_id)
  226. when "address"
  227. @organization.settings&.dig("address") || @organization.try(:address)
  228. when "city"
  229. @organization.settings&.dig("city") || @organization.try(:city)
  230. when "phone"
  231. @organization.settings&.dig("phone") || @organization.try(:phone)
  232. else
  233. @organization.settings&.dig(field) || @organization.try(field)
  234. end
  235. end
  236. def resolve_third_party_field(field)
  237. return nil unless @third_party
  238. case field
  239. when "display_name", "name"
  240. @third_party.display_name
  241. when "business_name"
  242. @third_party.business_name
  243. when "trade_name"
  244. @third_party.trade_name
  245. when "code"
  246. @third_party.code
  247. when "identification_number"
  248. @third_party.identification_number
  249. when "identification_type"
  250. @third_party.identification_type
  251. when "full_identification"
  252. "#{@third_party.identification_type} #{@third_party.identification_number}"
  253. when "third_party_type", "type"
  254. @third_party.type_label
  255. when "person_type"
  256. @third_party.person_type == "natural" ? "Persona Natural" : "Persona Jurídica"
  257. when "email"
  258. @third_party.email
  259. when "phone"
  260. @third_party.phone
  261. when "address"
  262. @third_party.address
  263. when "city"
  264. @third_party.city
  265. when "country"
  266. @third_party.country
  267. when "legal_rep_name"
  268. @third_party.legal_rep_name
  269. when "legal_rep_id"
  270. @third_party.legal_rep_id_number
  271. when "legal_rep_email"
  272. @third_party.legal_rep_email
  273. when "bank_name"
  274. @third_party.bank_name
  275. when "bank_account_type"
  276. @third_party.bank_account_type == "savings" ? "Ahorros" : "Corriente"
  277. when "bank_account_number"
  278. @third_party.bank_account_number
  279. when "industry"
  280. @third_party.industry
  281. else
  282. @third_party.try(field)
  283. end
  284. end
  285. def resolve_contract_field(field)
  286. # If there's a commercial contract, use it
  287. if @contract
  288. case field
  289. when "contract_number", "number"
  290. return @contract.contract_number
  291. when "title"
  292. return @contract.title
  293. when "description"
  294. return @contract.description
  295. when "contract_type", "type"
  296. return @contract.type_label
  297. when "status"
  298. return @contract.status_label
  299. when "amount"
  300. return format_currency(@contract.amount)
  301. when "amount_text"
  302. return number_to_words(@contract.amount)
  303. when "currency"
  304. return @contract.currency
  305. when "start_date"
  306. return format_date(@contract.start_date)
  307. when "start_date_text"
  308. return format_date_text(@contract.start_date)
  309. when "end_date"
  310. return format_date(@contract.end_date)
  311. when "end_date_text"
  312. return format_date_text(@contract.end_date)
  313. when "duration_days"
  314. return @contract.duration_days.to_s
  315. when "duration_text"
  316. return format_contract_duration_from_days(@contract.duration_days)
  317. when "payment_terms"
  318. return @contract.payment_terms
  319. when "payment_frequency"
  320. return format_payment_frequency(@contract.payment_frequency)
  321. when "approval_level"
  322. return @contract.approval_level_label
  323. when "approved_at"
  324. return format_date(@contract.approved_at)
  325. when "approved_at_text"
  326. return format_date_text(@contract.approved_at)
  327. else
  328. return @contract.try(field)
  329. end
  330. end
  331. # Fallback to employee contract data if no commercial contract but there's an employee
  332. # This handles HR templates that might incorrectly use contract.* mappings
  333. return nil unless @employee
  334. case field
  335. when "start_date"
  336. format_date(@employee.contract_start_date || @employee.hire_date)
  337. when "start_date_text"
  338. format_date_text(@employee.contract_start_date || @employee.hire_date)
  339. when "end_date"
  340. format_date(@employee.contract_end_date)
  341. when "end_date_text"
  342. format_date_text(@employee.contract_end_date)
  343. when "contract_type", "type"
  344. format_contract_type(@employee.contract_type)
  345. when "duration_text"
  346. format_contract_duration
  347. else
  348. nil
  349. end
  350. end
  351. def resolve_request_field(field)
  352. return nil unless @request
  353. case field
  354. when "request_number"
  355. @request.request_number
  356. when "certification_type"
  357. format_certification_type
  358. when "purpose"
  359. format_purpose
  360. when "start_date"
  361. format_date(@request.try(:start_date))
  362. when "end_date"
  363. format_date(@request.try(:end_date))
  364. when "days_requested"
  365. @request.try(:days_requested)
  366. when "vacation_type"
  367. format_vacation_type
  368. when "status"
  369. format_status
  370. when "submitted_at"
  371. format_date(@request.try(:submitted_at))
  372. else
  373. @request.try(field)
  374. end
  375. end
  376. def resolve_system_field(field)
  377. case field
  378. when "current_date"
  379. format_date(Date.current)
  380. when "current_date_text"
  381. format_date_text(Date.current)
  382. when "current_year"
  383. Date.current.year.to_s
  384. when "current_month"
  385. I18n.l(Date.current, format: "%B")
  386. when "current_month_year"
  387. I18n.l(Date.current, format: "%B de %Y")
  388. else
  389. nil
  390. end
  391. end
  392. # Custom variables that map to employee/organization data with transformations
  393. def resolve_custom_field(field)
  394. return nil unless @employee
  395. case field
  396. # Auxilio de alimentación en letras (toma de employee.food_allowance)
  397. when "auxilio_alimentacion_en_letras_y_pesos", "auxilio_de_alimentacion_en_letras"
  398. number_to_words(@employee.food_allowance)
  399. # Salario en letras (toma de employee.salary)
  400. when "salario_letras_y_pesos", "salario_en_letras"
  401. number_to_words(@employee.salary)
  402. # Auxilio de transporte en letras
  403. when "auxilio_transporte_en_letras_y_pesos", "auxilio_de_transporte_en_letras"
  404. number_to_words(@employee.transport_allowance)
  405. # Compensación total en letras
  406. when "compensacion_total_en_letras"
  407. total = (@employee.salary || 0) + (@employee.food_allowance || 0) + (@employee.transport_allowance || 0)
  408. number_to_words(total)
  409. # Ciudad de labores
  410. when "ciudad_labores", "ciudad_de_labores"
  411. @employee.work_city
  412. # Email del trabajador
  413. when "email_trabajador", "correo_trabajador", "email_empleado"
  414. @employee.personal_email || @employee.user&.email
  415. # Periodicidad de pago
  416. when "periodicidad_pago", "frecuencia_pago"
  417. format_payment_frequency(@employee.payment_frequency)
  418. else
  419. nil
  420. end
  421. end
  422. def format_date(date)
  423. return nil unless date
  424. date.strftime("%d/%m/%Y")
  425. end
  426. def format_date_text(date)
  427. return nil unless date
  428. I18n.l(date, format: :long, locale: :es)
  429. rescue StandardError
  430. date.strftime("%d de %B de %Y")
  431. end
  432. def calculate_years_of_service
  433. return nil unless @employee&.hire_date
  434. ((Date.current - @employee.hire_date) / 365.25).round(2)
  435. end
  436. def format_years_of_service
  437. years = calculate_years_of_service
  438. return nil unless years
  439. complete_years = years.floor
  440. months = ((years - complete_years) * 12).round
  441. if complete_years.zero? && months.zero?
  442. "menos de un mes"
  443. elsif complete_years.zero?
  444. "#{months} #{months == 1 ? 'mes' : 'meses'}"
  445. elsif months.zero?
  446. "#{complete_years} #{complete_years == 1 ? 'año' : 'años'}"
  447. else
  448. "#{complete_years} #{complete_years == 1 ? 'año' : 'años'} y #{months} #{months == 1 ? 'mes' : 'meses'}"
  449. end
  450. end
  451. def format_certification_type
  452. return nil unless @request.respond_to?(:certification_type)
  453. types = {
  454. "employment" => "Certificación Laboral",
  455. "salary" => "Certificación de Salario",
  456. "position" => "Certificación de Cargo",
  457. "full" => "Certificación Completa",
  458. "custom" => "Certificación Personalizada"
  459. }
  460. types[@request.certification_type] || @request.certification_type
  461. end
  462. def format_purpose
  463. return nil unless @request.respond_to?(:purpose)
  464. purposes = {
  465. "bank" => "Trámite Bancario",
  466. "visa" => "Solicitud de Visa",
  467. "rental" => "Arrendamiento",
  468. "government" => "Trámite Gubernamental",
  469. "legal" => "Proceso Legal",
  470. "other" => "Otro"
  471. }
  472. purposes[@request.purpose] || @request.purpose
  473. end
  474. def format_vacation_type
  475. return nil unless @request.respond_to?(:vacation_type)
  476. types = {
  477. "regular" => "Vacaciones Regulares",
  478. "accumulated" => "Vacaciones Acumuladas",
  479. "advance" => "Vacaciones Anticipadas",
  480. "partial" => "Vacaciones Parciales"
  481. }
  482. types[@request.vacation_type] || @request.vacation_type
  483. end
  484. def format_status
  485. return nil unless @request.respond_to?(:status)
  486. statuses = {
  487. "pending" => "Pendiente",
  488. "approved" => "Aprobado",
  489. "rejected" => "Rechazado",
  490. "processing" => "En Proceso",
  491. "completed" => "Completado",
  492. "cancelled" => "Cancelado"
  493. }
  494. statuses[@request.status] || @request.status
  495. end
  496. def format_currency(amount)
  497. return nil unless amount
  498. # Format as Colombian pesos
  499. formatted = amount.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse
  500. "$#{formatted}"
  501. end
  502. def number_to_words(amount)
  503. return nil unless amount
  504. # Basic Spanish number to words conversion
  505. units = %w[cero uno dos tres cuatro cinco seis siete ocho nueve]
  506. teens = %w[diez once doce trece catorce quince dieciseis diecisiete dieciocho diecinueve]
  507. tens = %w[veinte treinta cuarenta cincuenta sesenta setenta ochenta noventa]
  508. hundreds = ["", "ciento", "doscientos", "trescientos", "cuatrocientos",
  509. "quinientos", "seiscientos", "setecientos", "ochocientos", "novecientos"]
  510. n = amount.to_i
  511. return "cero pesos" if n.zero?
  512. parts = []
  513. # Millions
  514. if n >= 1_000_000
  515. millions = n / 1_000_000
  516. parts << (millions == 1 ? "un millón" : "#{number_to_words_helper(millions, units, teens, tens, hundreds)} millones")
  517. n %= 1_000_000
  518. end
  519. # Thousands
  520. if n >= 1000
  521. thousands = n / 1000
  522. parts << (thousands == 1 ? "mil" : "#{number_to_words_helper(thousands, units, teens, tens, hundreds)} mil")
  523. n %= 1000
  524. end
  525. # Hundreds and below
  526. parts << number_to_words_helper(n, units, teens, tens, hundreds) if n.positive?
  527. "#{parts.join(' ')} pesos"
  528. end
  529. def number_to_words_helper(n, units, teens, tens, hundreds)
  530. return "" if n.zero?
  531. return units[n] if n < 10
  532. return teens[n - 10] if n < 20
  533. return "veinti#{units[n - 20]}" if n < 30
  534. return tens[(n / 10) - 2] + (n % 10 > 0 ? " y #{units[n % 10]}" : "") if n < 100
  535. return "cien" if n == 100
  536. return hundreds[n / 100] + (n % 100 > 0 ? " #{number_to_words_helper(n % 100, units, teens, tens, hundreds)}" : "") if n < 1000
  537. n.to_s
  538. end
  539. def format_contract_type(contract_type)
  540. return nil unless contract_type
  541. types = {
  542. "indefinite" => "Término Indefinido",
  543. "fixed_term" => "Término Fijo",
  544. "work_or_labor" => "Obra o Labor",
  545. "apprentice" => "Aprendizaje"
  546. }
  547. types[contract_type] || contract_type
  548. end
  549. def format_contract_duration
  550. return nil unless @employee&.contract_duration_value
  551. value = @employee.contract_duration_value
  552. unit = @employee.contract_duration_unit || "months"
  553. case unit
  554. when "days"
  555. "#{value} #{value == 1 ? 'día' : 'días'}"
  556. when "weeks"
  557. "#{value} #{value == 1 ? 'semana' : 'semanas'}"
  558. when "months"
  559. format_duration_months(value)
  560. when "years"
  561. format_duration_years(value)
  562. else
  563. "#{value} #{unit}"
  564. end
  565. end
  566. def format_duration_months(months)
  567. if months >= 12
  568. years = months / 12
  569. remaining_months = months % 12
  570. if remaining_months.zero?
  571. "#{years} #{years == 1 ? 'año' : 'años'}"
  572. else
  573. "#{years} #{years == 1 ? 'año' : 'años'} y #{remaining_months} #{remaining_months == 1 ? 'mes' : 'meses'}"
  574. end
  575. else
  576. "#{months} #{months == 1 ? 'mes' : 'meses'}"
  577. end
  578. end
  579. def format_duration_years(years)
  580. "#{years} #{years == 1 ? 'año' : 'años'}"
  581. end
  582. def format_contract_duration_from_days(days)
  583. return nil unless days
  584. if days >= 365
  585. years = days / 365
  586. remaining_days = days % 365
  587. months = remaining_days / 30
  588. if months.positive?
  589. "#{years} #{years == 1 ? 'año' : 'años'} y #{months} #{months == 1 ? 'mes' : 'meses'}"
  590. else
  591. "#{years} #{years == 1 ? 'año' : 'años'}"
  592. end
  593. elsif days >= 30
  594. months = days / 30
  595. "#{months} #{months == 1 ? 'mes' : 'meses'}"
  596. else
  597. "#{days} #{days == 1 ? 'día' : 'días'}"
  598. end
  599. end
  600. def format_payment_frequency(frequency)
  601. return nil unless frequency
  602. frequencies = {
  603. "monthly" => "Mensual",
  604. "bimonthly" => "Bimestral",
  605. "quarterly" => "Trimestral",
  606. "semiannual" => "Semestral",
  607. "annual" => "Anual",
  608. "biweekly" => "Quincenal",
  609. "weekly" => "Semanal",
  610. "one_time" => "Pago Único",
  611. "milestone" => "Por Hitos",
  612. "upon_delivery" => "Contra Entrega"
  613. }
  614. frequencies[frequency] || frequency
  615. end
  616. end
  617. end

app/services/workflow/workflow_service.rb

0.0% lines covered

114 relevant lines. 0 lines covered and 114 lines missed.
    
  1. # frozen_string_literal: true
  2. module Workflow
  3. # Main service for workflow operations
  4. # Provides a high-level API for managing workflows
  5. #
  6. class WorkflowService
  7. attr_reader :user, :organization
  8. def initialize(user:, organization: nil)
  9. @user = user
  10. @organization = organization || user.organization
  11. end
  12. # Start a new workflow instance
  13. #
  14. # @param definition_name [String] Name of the workflow definition
  15. # @param document [Content::Document] Document to attach to workflow
  16. # @param context_data [Hash] Additional context data
  17. # @return [WorkflowInstance]
  18. def start_workflow(definition_name, document: nil, context_data: {})
  19. definition = find_definition(definition_name)
  20. instance = definition.create_instance!(
  21. document: document,
  22. initiated_by: user
  23. )
  24. instance.update!(context_data: context_data) if context_data.present?
  25. instance
  26. end
  27. # Get a workflow instance
  28. #
  29. # @param instance_id [String] ID of the instance
  30. # @return [WorkflowInstance]
  31. def find_instance(instance_id)
  32. WorkflowInstance.find(instance_id)
  33. end
  34. # Transition a workflow to a new state
  35. #
  36. # @param instance [WorkflowInstance] The workflow instance
  37. # @param to_state [String] Target state
  38. # @param comment [String] Optional comment
  39. # @return [WorkflowInstance]
  40. def transition(instance, to_state, comment: nil)
  41. action = find_action_for_transition(instance, to_state)
  42. instance.transition_to!(
  43. to_state,
  44. actor: user,
  45. action: action,
  46. comment: comment
  47. )
  48. end
  49. # Perform a named action on a workflow
  50. #
  51. # @param instance [WorkflowInstance] The workflow instance
  52. # @param action_name [String] Name of the action (e.g., "approve", "reject")
  53. # @param comment [String] Optional comment
  54. # @return [WorkflowInstance]
  55. def perform_action(instance, action_name, comment: nil)
  56. to_state = find_state_for_action(instance, action_name)
  57. unless to_state
  58. raise WorkflowError,
  59. "Action '#{action_name}' not available from state '#{instance.current_state}'"
  60. end
  61. instance.transition_to!(
  62. to_state,
  63. actor: user,
  64. action: action_name,
  65. comment: comment
  66. )
  67. end
  68. # Get available actions for current state
  69. #
  70. # @param instance [WorkflowInstance] The workflow instance
  71. # @return [Array<Hash>] Available actions
  72. def available_actions(instance)
  73. return [] unless instance.active?
  74. instance.definition.transitions
  75. .select { |t| t["from"] == instance.current_state }
  76. .map do |t|
  77. {
  78. action: t["action"],
  79. to_state: t["to"],
  80. label: action_label(t["action"])
  81. }
  82. end
  83. end
  84. # Cancel a workflow
  85. #
  86. # @param instance [WorkflowInstance] The workflow instance
  87. # @param reason [String] Cancellation reason
  88. # @return [WorkflowInstance]
  89. def cancel(instance, reason: nil)
  90. instance.cancel!(actor: user, reason: reason)
  91. end
  92. # Claim a task for the current user
  93. #
  94. # @param task [WorkflowTask] The task to claim
  95. # @return [WorkflowTask]
  96. def claim_task(task)
  97. task.claim!(user)
  98. end
  99. # Release a claimed task
  100. #
  101. # @param task [WorkflowTask] The task to release
  102. # @return [WorkflowTask]
  103. def release_task(task)
  104. task.release!(user)
  105. end
  106. # Get tasks assigned to current user's roles
  107. #
  108. # @return [Mongoid::Criteria]
  109. def my_tasks
  110. role_names = user.roles.pluck(:name)
  111. WorkflowTask.active
  112. .where(organization_id: organization.id)
  113. .where(:assigned_role.in => role_names)
  114. .or(assignee_id: user.id)
  115. .by_priority
  116. end
  117. # Get all active workflows for the organization
  118. #
  119. # @return [Mongoid::Criteria]
  120. def active_workflows
  121. WorkflowInstance.active
  122. .where(organization_id: organization.id)
  123. .order(started_at: :desc)
  124. end
  125. # Get workflows for a specific document
  126. #
  127. # @param document [Content::Document] The document
  128. # @return [Mongoid::Criteria]
  129. def workflows_for_document(document)
  130. WorkflowInstance.where(document_id: document.id)
  131. .order(started_at: :desc)
  132. end
  133. # Get workflow statistics
  134. #
  135. # @return [Hash]
  136. # rubocop:disable Metrics/AbcSize
  137. def statistics
  138. {
  139. active_workflows: WorkflowInstance.active.where(organization_id: organization.id).count,
  140. completed_today: WorkflowInstance.completed
  141. .where(organization_id: organization.id)
  142. .where(:completed_at.gte => Time.current.beginning_of_day)
  143. .count,
  144. pending_tasks: WorkflowTask.pending.where(organization_id: organization.id).count,
  145. overdue_tasks: WorkflowTask.overdue.where(organization_id: organization.id).count,
  146. my_pending_tasks: my_tasks.pending.count
  147. }
  148. end
  149. # rubocop:enable Metrics/AbcSize
  150. private
  151. def find_definition(name)
  152. definition = WorkflowDefinition.find_latest(name)
  153. raise WorkflowError, "Workflow definition '#{name}' not found" unless definition
  154. definition
  155. end
  156. def find_action_for_transition(instance, to_state)
  157. transition = instance.definition.transitions.find do |t|
  158. t["from"] == instance.current_state && t["to"] == to_state
  159. end
  160. transition&.dig("action")
  161. end
  162. def find_state_for_action(instance, action_name)
  163. transition = instance.definition.transitions.find do |t|
  164. t["from"] == instance.current_state && t["action"] == action_name
  165. end
  166. transition&.dig("to")
  167. end
  168. def action_label(action_name)
  169. action_name.to_s.titleize.tr("_", " ")
  170. end
  171. end
  172. end

lib/tasks/regenerate_previews.rb

0.0% lines covered

38 relevant lines. 0 lines covered and 38 lines missed.
    
  1. # Run with: rails runner lib/tasks/regenerate_previews.rb
  2. require "combine_pdf"
  3. puts "Regenerating ALL PDF previews locally..."
  4. puts "=" * 60
  5. templates = Templates::Template.where(:file_id.ne => nil).select { |t| t.file_name&.end_with?(".docx") }
  6. puts "Found #{templates.count} templates\n\n"
  7. regenerated = 0
  8. templates.each do |template|
  9. print " #{template.name}... "
  10. content = template.file_content
  11. unless content
  12. puts "No file content, skipped"
  13. next
  14. end
  15. begin
  16. pdf_content = template.send(:convert_docx_to_pdf_for_dimensions, content)
  17. if pdf_content
  18. template.store_pdf_preview!(pdf_content)
  19. # Update dimensions
  20. pdf = CombinePDF.parse(pdf_content)
  21. if pdf.pages.any?
  22. first_page = pdf.pages.first
  23. mediabox = first_page.mediabox
  24. template.pdf_width = mediabox[2].to_f
  25. template.pdf_height = mediabox[3].to_f
  26. template.pdf_page_count = pdf.pages.count
  27. end
  28. template.save!
  29. regenerated += 1
  30. puts "OK (#{pdf.pages.count} pages, #{pdf_content.bytesize} bytes)"
  31. else
  32. puts "Conversion failed"
  33. end
  34. rescue => e
  35. puts "ERROR: #{e.message}"
  36. end
  37. end
  38. puts "\n" + "=" * 60
  39. puts "Regenerated #{regenerated} previews"
  40. puts "=" * 60